Lior-0618 commited on
Commit
c4c4f17
Β·
1 Parent(s): bcf82e9

feat: replace frontend with redesigned Ethos Studio UI + update READMEs

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitattributes +0 -1
  2. api/README.md +75 -28
  3. proxy/README.md +35 -23
  4. web/.gitignore +41 -41
  5. web/README.md +42 -37
  6. web/components.json +22 -22
  7. web/eslint.config.mjs +18 -18
  8. web/next.config.ts +10 -7
  9. web/package-lock.json +0 -0
  10. web/package.json +38 -37
  11. web/postcss.config.mjs +7 -7
  12. web/public/file.svg +0 -1
  13. web/public/globe.svg +0 -1
  14. web/public/logo-black-bg.svg +6 -0
  15. web/public/logo.svg +5 -0
  16. web/public/next.svg +0 -1
  17. web/public/vercel.svg +0 -1
  18. web/public/window.svg +0 -1
  19. web/src/app/api/speech-to-text/route.ts +51 -0
  20. web/src/app/favicon.ico +0 -0
  21. web/src/app/globals.css +148 -148
  22. web/src/app/layout.tsx +43 -33
  23. web/src/app/page.tsx +180 -369
  24. web/src/app/studio/page.tsx +574 -528
  25. web/src/components/navbar.tsx +96 -33
  26. web/src/components/sidebar.tsx +178 -0
  27. web/src/components/ui/avatar.tsx +112 -112
  28. web/src/components/ui/badge.tsx +49 -49
  29. web/src/components/ui/button.tsx +65 -65
  30. web/src/components/ui/card.tsx +100 -100
  31. web/src/components/ui/collapsible.tsx +33 -33
  32. web/src/components/ui/dialog.tsx +165 -165
  33. web/src/components/ui/dropdown-menu.tsx +269 -269
  34. web/src/components/ui/hover-card.tsx +44 -44
  35. web/src/components/ui/input.tsx +19 -19
  36. web/src/components/ui/label.tsx +24 -24
  37. web/src/components/ui/progress.tsx +31 -31
  38. web/src/components/ui/scroll-area.tsx +55 -55
  39. web/src/components/ui/select.tsx +195 -195
  40. web/src/components/ui/separator.tsx +28 -28
  41. web/src/components/ui/sheet.tsx +144 -0
  42. web/src/components/ui/sidebar.tsx +705 -0
  43. web/src/components/ui/skeleton.tsx +13 -13
  44. web/src/components/ui/slider.tsx +59 -59
  45. web/src/components/ui/switch.tsx +33 -33
  46. web/src/components/ui/table.tsx +116 -116
  47. web/src/components/ui/tabs.tsx +90 -90
  48. web/src/components/ui/tooltip.tsx +57 -57
  49. web/src/hooks/use-mobile.ts +19 -0
  50. 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
- # Evoxtral Speech-to-Text (Model Layer)
2
 
3
- Proxy API that forwards audio to the external [YongkangZOU/evoxtral-lora](https://huggingface.co/YongkangZOU/evoxtral-lora) inference endpoint (hosted on Modal), then adds VAD sentence segmentation and emotion parsing from inline expression tags.
4
 
5
- **Requirements**: Python 3.10+, system **ffmpeg** (`brew install ffmpeg` / `apt install ffmpeg`). No GPU or local model download needed.
6
 
7
  ---
8
 
9
  ## Startup
10
 
11
  ```bash
12
- cd model/voxtral-server
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**. Starts instantly β€” inference is handled remotely.
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 forwarded to the evoxtral API.
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: transcription (via evoxtral API) + VAD sentence segmentation + per-segment emotion parsing.
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. Text is distributed by sentence boundaries, with character-level fallback.
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
- `diarization_method`: always `"vad"`.
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
- | 502 | Evoxtral external API unreachable or returned an error |
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 (external API)",
107
  "model_loaded": true,
108
  "ffmpeg": true,
109
- "pyannote_available": false,
110
- "hf_token_set": false,
111
- "max_upload_mb": 100,
112
- "evoxtral_api": "https://yongkang-zou1999--evoxtral-api-evoxtralmodel-web.modal.run"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- | `EVOXTRAL_API` | `https://yongkang-zou1999--evoxtral-api-evoxtralmodel-web.modal.run` | External evoxtral inference endpoint |
 
 
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
- # Transcription + VAD segmentation + emotion
134
  curl -X POST http://127.0.0.1:8000/transcribe-diarize -F "audio=@audio.m4a"
135
 
136
- # Health
137
- curl -s http://127.0.0.1:8000/health
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
- # Server Layer
2
 
3
- Node proxy service. Exposes the client-facing API and forwards requests to the **Model layer** (voxtral-server).
4
 
5
  - **Port**: `3000` (override with `PORT`)
6
- - **Model layer URL**: `http://127.0.0.1:8000` (override with `MODEL_URL`)
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 18+**.
20
 
21
  ---
22
 
@@ -24,7 +25,7 @@ Requires **Node.js 18+**.
24
 
25
  ### POST /api/speech-to-text
26
 
27
- Simple transcription. Forwarded to Model layer `POST /transcribe`. Timeout: **5 min**.
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": null
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 | Model layer error or unreachable |
51
- | 504 | `{"error": "Request timeout (>5 min); try shorter audio"}` |
52
 
53
  ---
54
 
55
  ### POST /api/transcribe-diarize
56
 
57
- Transcription + VAD sentence segmentation + emotion analysis. Forwarded to Model layer `POST /transcribe-diarize`. Timeout: **10 min**. All segments are labelled `SPEAKER_00`.
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": "neutral",
77
- "valence": 0.1,
78
- "arousal": 0.2
 
79
  }
80
  ],
81
  "duration": 42.3,
82
  "text": "full transcript",
83
- "filename": "recording.m4a",
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 | Model layer error or unreachable |
94
- | 504 | `{"error": "Request timeout (>10 min); try shorter audio"}` |
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-4B-Realtime-2602",
111
  "model_loaded": true,
112
  "ffmpeg": true,
113
- "pyannote_available": false,
114
- "hf_token_set": false,
115
  "max_upload_mb": 100
116
  }
117
  }
118
  ```
119
 
120
- **Response (502)** β€” when Model layer is unreachable:
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
- # demo β€” Frontend & Server Layer
2
 
3
- This folder contains the Next.js frontend (Ethos Studio) and the Node proxy server.
 
 
4
 
5
  ## Architecture
6
 
7
  ```
8
  Browser (port 3030)
9
- β†’ Server layer (Node, port 3000) POST /api/speech-to-text, POST /api/transcribe-diarize, GET /health
10
- β†’ Model layer (Python, port 8000) POST /transcribe, POST /transcribe-diarize, GET /health
11
  ```
12
 
13
- - **Frontend** (`demo/`): Upload page + Studio editor. Calls the Server layer. See [Startup β†’ 3](#3-frontend-nextjs-port-3030).
14
- - **Server layer** (`demo/server/`): Proxies client requests to the Model layer. See [demo/server/README.md](server/README.md) for API details.
15
- - **Model layer** (`model/voxtral-server/`): Voxtral inference + diarization + emotion analysis. See [model/voxtral-server/README.md](../model/voxtral-server/README.md) for API details.
16
 
17
  ---
18
 
19
  ## Startup
20
 
21
- ### 1. Model layer (Python, port 8000)
22
 
23
- Requires **Python 3.10+** and **ffmpeg**.
24
 
25
  ```bash
26
- cd ../model/voxtral-server
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
- First run may download the model (~8–16GB).
34
 
35
- ### 2. Server layer (Node, port 3000)
36
 
37
  ```bash
38
- cd server
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 .. # back to demo/
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 file, choose language and options, then **Upload**. The file is sent to `/api/transcribe-diarize` and results open in the Studio.
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 (Server layer)
67
 
68
- Clients should call the **Server layer** only. The Model layer is used internally.
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 5 min |
79
 
80
  **Response (200)**
81
 
@@ -83,7 +86,7 @@ Simple transcription without diarization.
83
  {
84
  "text": "transcribed text",
85
  "words": [],
86
- "languageCode": null
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. All segments are labelled `SPEAKER_00`.
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 10 min |
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": "neutral",
114
- "valence": 0.1,
115
- "arousal": 0.2
 
116
  }
117
  ],
118
  "duration": 42.3,
119
  "text": "full transcript text",
120
- "filename": "recording.m4a",
121
- "diarization_method": "vad"
 
122
  }
123
  ```
124
 
125
- `diarization_method`: `"vad"` (silence-gap segmentation by sentence boundaries).
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-4B-Realtime-2602",
138
  "model_loaded": true,
139
  "ffmpeg": true,
140
- "pyannote_available": false,
141
- "hf_token_set": false,
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 `demo/.env.local` (copy from `.env.example`):
152
 
153
  | Variable | Default | Description |
154
  |----------|---------|-------------|
155
- | `NEXT_PUBLIC_API_URL` | `http://localhost:3000` | Server layer URL used by the browser |
156
 
157
- Create `demo/server/.env` or export:
158
 
159
  | Variable | Default | Description |
160
  |----------|---------|-------------|
161
- | `PORT` | `3000` | Server layer port |
162
- | `MODEL_URL` | `http://127.0.0.1:8000` | Model layer URL |
 
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
- const nextConfig: NextConfig = {
4
- output: "standalone",
5
- };
6
-
7
- export default nextConfig;
 
 
 
 
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
- "tailwind-merge": "^3.5.0"
24
- },
25
- "devDependencies": {
26
- "@tailwindcss/postcss": "^4",
27
- "@types/node": "^20",
28
- "@types/react": "^19",
29
- "@types/react-dom": "^19",
30
- "eslint": "^9",
31
- "eslint-config-next": "16.1.6",
32
- "shadcn": "^3.8.5",
33
- "tailwindcss": "^4",
34
- "tw-animate-css": "^1.4.0",
35
- "typescript": "^5"
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.875rem;
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 "./globals.css";
4
- import { TooltipProvider } from "@/components/ui/tooltip";
5
-
6
- const lato = Lato({
7
- weight: ["100", "300", "400", "700", "900"],
8
- variable: "--font-lato",
9
- subsets: ["latin"],
10
- });
11
-
12
- export const metadata: Metadata = {
13
- title: "Ethos Studio | Emotional Speech Recognition",
14
- description: "Advanced emotional speech recognition and transcription studio powered by Ethostral.",
15
- };
16
-
17
- export default function RootLayout({
18
- children,
19
- }: Readonly<{
20
- children: React.ReactNode;
21
- }>) {
22
- return (
23
- <html lang="en">
24
- <body
25
- className={`${lato.variable} antialiased selection:bg-black/10 font-sans`}
26
- >
27
- <TooltipProvider>
28
- {children}
29
- </TooltipProvider>
30
- </body>
31
- </html>
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 Link from "next/link"
5
- import { useRouter } from "next/navigation"
6
- import {
7
- Microphone, MagnifyingGlass, DotsThreeVertical,
8
- UploadSimple, Play, Clock,
9
- } from "@phosphor-icons/react"
10
- import { Button } from "@/components/ui/button"
11
- import { Input } from "@/components/ui/input"
12
- import { Separator } from "@/components/ui/separator"
13
- import { Switch } from "@/components/ui/switch"
14
- import { Label } from "@/components/ui/label"
15
- import {
16
- Dialog, DialogContent, DialogHeader, DialogTitle,
17
- } from "@/components/ui/dialog"
18
- import {
19
- Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
20
- } from "@/components/ui/select"
21
- import {
22
- Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
23
- } from "@/components/ui/table"
24
- import {
25
- DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
26
- } from "@/components/ui/dropdown-menu"
27
- import { Navbar } from "@/components/navbar"
28
- import { Skeleton } from "@/components/ui/skeleton"
29
- import { createSession, type DiarizeResult } from "@/lib/session-store"
30
-
31
- // --- Constants ---
32
- const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"
33
- const MAX_FILE_BYTES = 100 * 1024 * 1024
34
-
35
- // --- Mock sessions (demo history) ---
36
- const MOCK_SESSIONS = [
37
- { id: "demo-1", title: "Team_Standup_2026-02-28.mp4", createdAt: "2 days ago" },
38
- { id: "demo-2", title: "Customer_Interview_Batch_7.wav", createdAt: "5 days ago" },
39
- { id: "demo-3", title: "Podcast_Episode_14.mp3", createdAt: "1 week ago" },
40
- { id: "demo-4", title: "WeChat_20250804025710.mp4", createdAt: "7 months ago" },
41
- ]
42
-
43
- // --- Upload Dialog ---
44
- function UploadDialog({
45
- open,
46
- onOpenChange,
47
- }: {
48
- open: boolean
49
- onOpenChange: (v: boolean) => void
50
- }) {
51
- const router = useRouter()
52
- const [file, setFile] = useState<File | null>(null)
53
- const [loading, setLoading] = useState(false)
54
- const [progress, setProgress] = useState<string>("")
55
- const [error, setError] = useState<string | null>(null)
56
- const inputRef = React.useRef<HTMLInputElement>(null)
57
-
58
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
59
- const f = e.target.files?.[0] ?? null
60
- setFile(f)
61
- setError(null)
62
- setProgress("")
63
- }
64
-
65
- const handleDrop = (e: React.DragEvent) => {
66
- e.preventDefault()
67
- const f = e.dataTransfer.files?.[0]
68
- if (
69
- f &&
70
- (f.type.startsWith("audio/") ||
71
- f.type.startsWith("video/") ||
72
- /\.(wav|mp3|m4a|webm|ogg|flac|mp4)$/i.test(f.name))
73
- ) {
74
- setFile(f)
75
- setError(null)
76
- setProgress("")
77
- }
78
- }
79
-
80
- const handleUpload = async () => {
81
- if (!file) {
82
- setError("Please select an audio or video file")
83
- return
84
- }
85
- if (file.size > MAX_FILE_BYTES) {
86
- setError(`File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 100 MB.`)
87
- return
88
- }
89
-
90
- setLoading(true)
91
- setError(null)
92
- setProgress("Uploading…")
93
-
94
- const isVideo = file.type.startsWith("video/") || /\.(mp4|mkv|avi|mov|m4v)$/i.test(file.name)
95
-
96
- try {
97
- const formData = new FormData()
98
- formData.append("audio", file, file.name)
99
-
100
- setProgress(isVideo
101
- ? "Transcribing and analyzing facial emotions…"
102
- : "Transcribing and analyzing speakers…"
103
- )
104
- const res = await fetch(
105
- `${API_BASE}/api/transcribe-diarize`,
106
- { method: "POST", body: formData }
107
- )
108
- const data = await res.json().catch(() => ({}))
109
-
110
- if (!res.ok) {
111
- setError((data as { error?: string }).error ?? "Transcription failed")
112
- return
113
- }
114
-
115
- setProgress("Processing results…")
116
- const result = data as DiarizeResult
117
- const sessionId = createSession(file, result)
118
-
119
- // Navigate to studio with session ID
120
- router.push(`/studio?s=${sessionId}`)
121
- onOpenChange(false)
122
- } catch (e) {
123
- setError(e instanceof Error ? e.message : "Request failed")
124
- } finally {
125
- setLoading(false)
126
- setProgress("")
127
- }
128
- }
129
-
130
- const handleOpenChange = (v: boolean) => {
131
- if (!v && !loading) {
132
- setFile(null)
133
- setError(null)
134
- setProgress("")
135
- }
136
- if (!loading) onOpenChange(v)
137
- }
138
-
139
- return (
140
- <Dialog open={open} onOpenChange={handleOpenChange}>
141
- <DialogContent className="sm:max-w-[480px]">
142
- <DialogHeader>
143
- <DialogTitle>Transcribe files</DialogTitle>
144
- </DialogHeader>
145
-
146
- {/* Drop Zone */}
147
- <div
148
- className={`border-2 border-dashed rounded-lg px-6 py-10 flex flex-col items-center gap-2 text-center transition-colors ${
149
- loading
150
- ? "border-border opacity-50 cursor-not-allowed"
151
- : "border-border cursor-pointer hover:border-foreground/30 hover:bg-muted/40"
152
- }`}
153
- onClick={() => !loading && inputRef.current?.click()}
154
- onDrop={loading ? undefined : handleDrop}
155
- onDragOver={(e) => e.preventDefault()}
156
- >
157
- <input
158
- ref={inputRef}
159
- type="file"
160
- accept="audio/*,video/*,.wav,.mp3,.m4a,.webm,.ogg,.flac,.mp4"
161
- className="hidden"
162
- onChange={handleFileChange}
163
- disabled={loading}
164
- />
165
- <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center mb-1">
166
- {loading ? (
167
- <div className="w-5 h-5 border-2 border-foreground/30 border-t-foreground rounded-full animate-spin" />
168
- ) : (
169
- <UploadSimple size={20} className="text-muted-foreground" />
170
- )}
171
- </div>
172
- <p className="text-sm font-semibold text-foreground">
173
- {loading ? progress : "Click or drag files here to upload"}
174
- </p>
175
- <p className="text-xs text-muted-foreground">
176
- {file ? `${file.name} Β· ${(file.size / 1024).toFixed(0)} KB` : "Audio & video files, up to 100 MB"}
177
- </p>
178
- </div>
179
-
180
- {error && (
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
- // --- Constants ---
28
- const SPEAKER_COLORS = [
29
- { avatar: "bg-blue-400", track: "bg-blue-200" },
30
- { avatar: "bg-pink-400", track: "bg-pink-200" },
31
- { avatar: "bg-emerald-400", track: "bg-emerald-200" },
32
- { avatar: "bg-amber-400", track: "bg-amber-200" },
33
- { avatar: "bg-violet-400", track: "bg-violet-200" },
34
- { avatar: "bg-cyan-400", track: "bg-cyan-200" },
35
- { avatar: "bg-rose-400", track: "bg-rose-200" },
36
- { avatar: "bg-lime-400", track: "bg-lime-200" },
37
- ]
38
-
39
- // --- Mock Data (demo / fallback) ---
40
- const MOCK_SEGMENTS: Segment[] = [
41
- { id: 1, speaker: "SPEAKER_00", start: 0.10, end: 7.28, text: "[instrumental music plays]", emotion: "Neutral", valence: 0.0, arousal: 0.1 },
42
- { id: 2, speaker: "SPEAKER_00", start: 8.10, end: 9.04, text: "Hello, I'm here.", emotion: "Calm", valence: 0.3, arousal: -0.1 },
43
- { id: 3, speaker: "SPEAKER_01", start: 10.62, end: 11.00, text: "Oh.", emotion: "Surprise", valence: 0.4, arousal: 0.6 },
44
- { id: 4, speaker: "SPEAKER_01", start: 13.02, end: 14.18, text: "Hi.", emotion: "Neutral", valence: 0.1, arousal: 0.0 },
45
- { id: 5, speaker: "SPEAKER_00", start: 14.82, end: 16.40, text: "Hi.", emotion: "Happy", valence: 0.7, arousal: 0.4 },
46
- { id: 6, speaker: "SPEAKER_01", start: 17.20, end: 22.10, text: "It's been a long time coming. Are you nervous?", emotion: "Curious", valence: 0.2, arousal: 0.5 },
47
- { id: 7, speaker: "SPEAKER_00", start: 22.90, end: 28.50, text: "A little bit. The data is just... it's a lot to process.", emotion: "Anxiety", valence: -0.4, arousal: 0.7 },
48
- ]
49
- const MOCK_FILENAME = "WeChat_20250804025710.mp4"
50
- const MOCK_DURATION = 28.50
51
-
52
- // --- Helpers ---
53
- function fmtTime(sec: number): string {
54
- const m = Math.floor(sec / 60)
55
- const s = (sec % 60).toFixed(2)
56
- return m > 0 ? `${m}:${s.padStart(5, "0")}` : s
57
- }
58
-
59
- type SpeakerInfo = { label: string; avatarColor: string; trackColor: string }
60
-
61
- function buildSpeakerMap(segments: Segment[]): Record<string, SpeakerInfo> {
62
- const speakers = [...new Set(segments.map(s => s.speaker))].sort()
63
- return Object.fromEntries(
64
- speakers.map((id, i) => {
65
- const palette = SPEAKER_COLORS[i % SPEAKER_COLORS.length]
66
- return [id, { label: `Speaker ${i + 1}`, avatarColor: palette.avatar, trackColor: palette.track }]
67
- })
68
- )
69
- }
70
-
71
- // --- SegmentRow ---
72
- function SegmentRow({
73
- seg,
74
- speakerMap,
75
- active,
76
- onClick,
77
- }: {
78
- seg: Segment
79
- speakerMap: Record<string, SpeakerInfo>
80
- active: boolean
81
- onClick: () => void
82
- }) {
83
- const speaker = speakerMap[seg.speaker] ?? { label: seg.speaker, avatarColor: "bg-gray-400", trackColor: "bg-gray-200" }
84
- return (
85
- <div
86
- onClick={onClick}
87
- className={`flex gap-0 group transition-colors cursor-pointer border-b border-border last:border-0 ${
88
- active ? "bg-accent" : "hover:bg-muted/40"
89
- }`}
90
- >
91
- {/* Speaker col */}
92
- <div className="w-36 flex-shrink-0 flex items-start gap-2 px-4 py-4">
93
- <Avatar className="w-7 h-7 flex-shrink-0 mt-0.5">
94
- <AvatarFallback className={`${speaker.avatarColor} text-white text-[10px] font-bold`}>
95
- {speaker.label[0]}
96
- </AvatarFallback>
97
- </Avatar>
98
- <span className="text-xs font-bold text-foreground leading-tight mt-1">{speaker.label}</span>
99
- </div>
100
-
101
- <Separator orientation="vertical" className="h-auto" />
102
-
103
- {/* Time + text col */}
104
- <div className="flex-1 px-5 py-4 space-y-1 min-w-0">
105
- <p className="text-[11px] text-muted-foreground font-medium">{fmtTime(seg.start)}</p>
106
- <p className="text-sm text-foreground leading-relaxed">{seg.text}</p>
107
- <p className="text-[11px] text-muted-foreground font-medium">{fmtTime(seg.end)}</p>
108
- </div>
109
-
110
- {/* Emotion badges */}
111
- <div className="flex-shrink-0 flex flex-col items-end gap-1 pt-4 pr-4">
112
- <Badge
113
- variant="outline"
114
- className="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0 border-border text-muted-foreground"
115
- >
116
- {seg.emotion}
117
- </Badge>
118
- {seg.face_emotion && (
119
- <Badge
120
- variant="outline"
121
- className="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0 border-border text-muted-foreground/60"
122
- >
123
- {seg.face_emotion}
124
- </Badge>
125
- )}
126
- </div>
127
- </div>
128
- )
129
- }
130
-
131
- // --- RightPanel ---
132
- function RightPanel({
133
- activeSegment,
134
- audioUrl,
135
- filename,
136
- isPlaying,
137
- isVideo,
138
- mediaRef,
139
- currentTime,
140
- duration,
141
- onToggle,
142
- }: {
143
- activeSegment: Segment | null
144
- audioUrl: string
145
- filename: string
146
- isPlaying: boolean
147
- isVideo: boolean
148
- mediaRef: React.RefObject<HTMLVideoElement | null>
149
- currentTime: number
150
- duration: number
151
- onToggle: () => void
152
- }) {
153
- const progress = duration > 0 ? (currentTime / duration) * 100 : 0
154
-
155
- return (
156
- <div className="flex flex-col h-full border-l border-border bg-card">
157
- {/* Media preview panel */}
158
- <div className="aspect-video w-full bg-slate-950 flex items-center justify-center flex-shrink-0 relative overflow-hidden">
159
- {isVideo && audioUrl ? (
160
- <video
161
- ref={mediaRef}
162
- src={audioUrl}
163
- preload="metadata"
164
- className="w-full h-full object-contain"
165
- />
166
- ) : (
167
- <>
168
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/20">
169
- <Waveform size={36} />
170
- <span className="text-[10px] font-bold uppercase tracking-widest truncate max-w-[80%] text-center px-2">
171
- {filename}
172
- </span>
173
- </div>
174
- <video ref={mediaRef} src={audioUrl || undefined} preload="metadata" className="hidden" />
175
- </>
176
- )}
177
- {/* Controls bar */}
178
- <div className="absolute bottom-0 left-0 right-0 h-8 bg-black/70 flex items-center px-2 gap-2">
179
- <button
180
- onClick={onToggle}
181
- disabled={!audioUrl}
182
- className="text-white/80 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
183
- >
184
- {isPlaying
185
- ? <Pause size={12} weight="fill" />
186
- : <Play size={12} weight="fill" />
187
- }
188
- </button>
189
- <div className="flex-1 h-[2px] bg-white/20 rounded-full mx-1">
190
- <div
191
- className="h-full bg-white/60 rounded-full transition-[width]"
192
- style={{ width: `${progress}%` }}
193
- />
194
- </div>
195
- <span className="text-white/40 text-[9px] font-mono">{fmtTime(currentTime)}</span>
196
- </div>
197
- </div>
198
-
199
- <ScrollArea className="flex-1">
200
- <div className="p-4 space-y-4">
201
- {/* Global Properties */}
202
- <Collapsible defaultOpen>
203
- <CollapsibleTrigger className="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase tracking-widest hover:text-foreground transition-colors w-full text-left">
204
- <span>β–Ύ</span> Global Properties
205
- </CollapsibleTrigger>
206
- <CollapsibleContent>
207
- <div className="mt-3 space-y-3 text-sm">
208
- <div className="flex items-center justify-between">
209
- <span className="font-semibold text-foreground truncate max-w-[160px] text-xs">{filename}</span>
210
- </div>
211
- <Separator />
212
- <div className="flex items-center justify-between">
213
- <span className="text-muted-foreground text-xs">Duration</span>
214
- <span className="font-semibold text-xs">{fmtTime(duration)}</span>
215
- </div>
216
- <div className="flex items-center justify-between">
217
- <span className="text-muted-foreground text-xs">Subtitles</span>
218
- <DropdownMenu>
219
- <DropdownMenuTrigger asChild>
220
- <Button variant="ghost" size="icon" className="h-6 w-6">
221
- <DotsThreeVertical size={14} />
222
- </Button>
223
- </DropdownMenuTrigger>
224
- <DropdownMenuContent>
225
- <DropdownMenuItem>Export SRT</DropdownMenuItem>
226
- <DropdownMenuItem>Export VTT</DropdownMenuItem>
227
- </DropdownMenuContent>
228
- </DropdownMenu>
229
- </div>
230
- </div>
231
- </CollapsibleContent>
232
- </Collapsible>
233
-
234
- <Separator />
235
-
236
- {/* Emotional Analysis */}
237
- <Collapsible defaultOpen>
238
- <CollapsibleTrigger className="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase tracking-widest hover:text-foreground transition-colors w-full text-left">
239
- <span>β–Ύ</span> Emotional Analysis
240
- </CollapsibleTrigger>
241
- <CollapsibleContent>
242
- <div className="mt-3 space-y-3">
243
- {activeSegment ? (
244
- <>
245
- <div className="flex items-center justify-between text-xs">
246
- <span className="text-muted-foreground">Voice Emotion</span>
247
- <Badge variant="outline" className="font-bold text-[10px]">{activeSegment.emotion}</Badge>
248
- </div>
249
- {activeSegment.face_emotion && (
250
- <div className="flex items-center justify-between text-xs">
251
- <span className="text-muted-foreground">Face Emotion</span>
252
- <Badge variant="outline" className="font-bold text-[10px] text-muted-foreground/70">{activeSegment.face_emotion}</Badge>
253
- </div>
254
- )}
255
- <div className="space-y-1">
256
- <div className="flex items-center justify-between text-[11px] text-muted-foreground">
257
- <span>Valence</span>
258
- <span className="font-bold text-foreground">{((activeSegment.valence + 1) / 2 * 100).toFixed(0)}%</span>
259
- </div>
260
- <Progress value={(activeSegment.valence + 1) / 2 * 100} className="h-1.5" />
261
- </div>
262
- <div className="space-y-1">
263
- <div className="flex items-center justify-between text-[11px] text-muted-foreground">
264
- <span>Arousal</span>
265
- <span className="font-bold text-foreground">{((activeSegment.arousal + 1) / 2 * 100).toFixed(0)}%</span>
266
- </div>
267
- <Progress value={(activeSegment.arousal + 1) / 2 * 100} className="h-1.5" />
268
- </div>
269
- </>
270
- ) : (
271
- <p className="text-xs text-muted-foreground">Select a segment to view analysis.</p>
272
- )}
273
- </div>
274
- </CollapsibleContent>
275
- </Collapsible>
276
- </div>
277
- </ScrollArea>
278
- </div>
279
- )
280
- }
281
-
282
- // --- TimelineBar ---
283
- function TimelineBar({
284
- isPlaying,
285
- onToggle,
286
- segments,
287
- speakerMap,
288
- duration,
289
- currentTime,
290
- }: {
291
- isPlaying: boolean
292
- onToggle: () => void
293
- segments: Segment[]
294
- speakerMap: Record<string, SpeakerInfo>
295
- duration: number
296
- currentTime: number
297
- }) {
298
- const speakers = Object.keys(speakerMap)
299
-
300
- return (
301
- <div className="border-t border-border bg-card flex-shrink-0">
302
- {/* Speaker track rows */}
303
- <div className="border-b border-border">
304
- {speakers.map((speakerId) => {
305
- const info = speakerMap[speakerId]
306
- const speakerSegs = segments.filter(s => s.speaker === speakerId)
307
-
308
- return (
309
- <div key={speakerId} className="flex items-center px-3 py-1.5 gap-2 group">
310
- <Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground cursor-grab">
311
- <DotsThreeVertical size={12} />
312
- </Button>
313
- <Avatar className="w-5 h-5">
314
- <AvatarFallback className={`${info.avatarColor} text-white text-[8px] font-bold`}>S</AvatarFallback>
315
- </Avatar>
316
- <span className="text-xs font-semibold text-foreground w-20">{info.label}</span>
317
- <Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground ml-auto opacity-0 group-hover:opacity-100">
318
- <DotsThreeVertical size={12} />
319
- </Button>
320
- {/* Track visualization */}
321
- <div className="flex-1 h-5 relative overflow-hidden rounded-sm bg-muted/30">
322
- {duration > 0 && speakerSegs.map(seg => (
323
- <div
324
- key={seg.id}
325
- className={`absolute top-0.5 bottom-0.5 ${info.trackColor} rounded-sm`}
326
- style={{
327
- left: `${(seg.start / duration) * 100}%`,
328
- width: `${Math.max(((seg.end - seg.start) / duration) * 100, 0.5)}%`,
329
- }}
330
- />
331
- ))}
332
- {/* Playhead */}
333
- {duration > 0 && (
334
- <div
335
- className="absolute top-0 bottom-0 w-px bg-foreground/60 z-10"
336
- style={{ left: `${(currentTime / duration) * 100}%` }}
337
- />
338
- )}
339
- </div>
340
- </div>
341
- )
342
- })}
343
- </div>
344
-
345
- {/* Playback controls */}
346
- <div className="flex items-center gap-4 px-4 h-10">
347
- {/* Zoom */}
348
- <div className="flex items-center gap-1.5 text-muted-foreground">
349
- <MinusCircle size={14} />
350
- <Slider defaultValue={[40]} max={100} className="w-16" />
351
- <PlusCircle size={14} />
352
- </div>
353
-
354
- <Separator orientation="vertical" className="h-5" />
355
-
356
- {/* Play / speed */}
357
- <div className="flex items-center gap-3">
358
- <Button
359
- size="icon"
360
- className="h-7 w-7 rounded-full"
361
- onClick={onToggle}
362
- >
363
- {isPlaying
364
- ? <Pause size={13} weight="fill" />
365
- : <Play size={13} weight="fill" />
366
- }
367
- </Button>
368
- <span className="text-xs font-bold text-muted-foreground">1.0Γ—</span>
369
- </div>
370
-
371
- <div className="flex-1" />
372
-
373
- <Button variant="ghost" size="sm" className="text-xs text-muted-foreground font-semibold gap-1.5 h-7">
374
- <Plus size={13} />
375
- Add segment
376
- </Button>
377
- </div>
378
- </div>
379
- )
380
- }
381
-
382
- // --- Studio Content ---
383
- function StudioContent() {
384
- const searchParams = useSearchParams()
385
- const sessionId = searchParams.get("s")
386
-
387
- const mediaRef = useRef<HTMLVideoElement>(null)
388
- const [activeId, setActiveId] = useState<number>(1)
389
- const [isPlaying, setIsPlaying] = useState(false)
390
- const [currentTime, setCurrentTime] = useState(0)
391
-
392
- const session = sessionId ? getSession(sessionId) : null
393
- const segments = session?.data.segments ?? MOCK_SEGMENTS
394
- const audioUrl = session?.audioUrl ?? ""
395
- const filename = session?.filename ?? MOCK_FILENAME
396
- const duration = session?.data.duration ?? MOCK_DURATION
397
- const isVideo = session?.data.has_video ?? false
398
-
399
- const speakerMap = useMemo(() => buildSpeakerMap(segments), [segments])
400
- const activeSegment = segments.find(s => s.id === activeId) ?? segments[0] ?? null
401
-
402
- useEffect(() => {
403
- const media = mediaRef.current
404
- if (!media) return
405
- const onTime = () => setCurrentTime(media.currentTime)
406
- const onPlay = () => setIsPlaying(true)
407
- const onPause = () => setIsPlaying(false)
408
- media.addEventListener("timeupdate", onTime)
409
- media.addEventListener("play", onPlay)
410
- media.addEventListener("pause", onPause)
411
- return () => {
412
- media.removeEventListener("timeupdate", onTime)
413
- media.removeEventListener("play", onPlay)
414
- media.removeEventListener("pause", onPause)
415
- }
416
- }, [])
417
-
418
- const handleSegmentClick = (seg: Segment) => {
419
- setActiveId(seg.id)
420
- if (mediaRef.current && audioUrl) {
421
- mediaRef.current.currentTime = seg.start
422
- }
423
- }
424
-
425
- const togglePlay = () => {
426
- const media = mediaRef.current
427
- if (!media || !audioUrl) return
428
- if (isPlaying) media.pause()
429
- else media.play()
430
- }
431
-
432
- return (
433
- <div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
434
- {/* Top Bar */}
435
- <header className="h-11 border-b border-border bg-card flex items-center px-4 gap-3 flex-shrink-0 sticky top-0 z-50">
436
- <Link href="/">
437
- <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground">
438
- <ArrowLeft size={16} />
439
- </Button>
440
- </Link>
441
-
442
- <div className="flex items-center gap-0.5 ml-1">
443
- <Tooltip>
444
- <TooltipTrigger asChild>
445
- <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
446
- <ArrowCounterClockwise size={15} />
447
- </Button>
448
- </TooltipTrigger>
449
- <TooltipContent>Undo</TooltipContent>
450
- </Tooltip>
451
- <Tooltip>
452
- <TooltipTrigger asChild>
453
- <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
454
- <ArrowClockwise size={15} />
455
- </Button>
456
- </TooltipTrigger>
457
- <TooltipContent>Redo</TooltipContent>
458
- </Tooltip>
459
- </div>
460
-
461
- <div className="flex-1" />
462
-
463
- <Button className="gap-1.5 font-bold h-8 text-xs px-3">
464
- <Export size={14} weight="bold" />
465
- Export
466
- </Button>
467
- </header>
468
-
469
- {/* Body */}
470
- <div className="flex flex-1 overflow-hidden">
471
- {/* Left: Transcript */}
472
- <div className="flex-1 overflow-hidden flex flex-col min-w-0">
473
- <ScrollArea className="flex-1">
474
- <div className="max-w-2xl mx-auto py-4">
475
- {segments.map(seg => (
476
- <SegmentRow
477
- key={seg.id}
478
- seg={seg}
479
- speakerMap={speakerMap}
480
- active={seg.id === activeId}
481
- onClick={() => handleSegmentClick(seg)}
482
- />
483
- ))}
484
- </div>
485
- </ScrollArea>
486
- </div>
487
-
488
- {/* Right: Properties panel */}
489
- <div className="w-[280px] flex-shrink-0 overflow-hidden flex flex-col">
490
- <RightPanel
491
- activeSegment={activeSegment}
492
- audioUrl={audioUrl}
493
- filename={filename}
494
- isPlaying={isPlaying}
495
- isVideo={isVideo}
496
- mediaRef={mediaRef}
497
- currentTime={currentTime}
498
- duration={duration}
499
- onToggle={togglePlay}
500
- />
501
- </div>
502
- </div>
503
-
504
- {/* Bottom: Timeline */}
505
- <TimelineBar
506
- isPlaying={isPlaying}
507
- onToggle={togglePlay}
508
- segments={segments}
509
- speakerMap={speakerMap}
510
- duration={duration}
511
- currentTime={currentTime}
512
- />
513
- </div>
514
- )
515
- }
516
-
517
- // --- Page (wraps in Suspense for useSearchParams) ---
518
- export default function StudioPage() {
519
- return (
520
- <Suspense fallback={
521
- <div className="h-screen flex items-center justify-center text-muted-foreground text-sm">
522
- Loading…
523
- </div>
524
- }>
525
- <StudioContent />
526
- </Suspense>
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 { Sparkle, Bell } from "@phosphor-icons/react"
5
- import { Button } from "@/components/ui/button"
6
- import { Avatar, AvatarFallback } from "@/components/ui/avatar"
7
- import { Separator } from "@/components/ui/separator"
8
- import { Skeleton } from "@/components/ui/skeleton"
9
-
10
- export function Navbar() {
11
- return (
12
- <header className="h-12 border-b border-border bg-card flex items-center justify-between px-5 sticky top-0 z-50">
13
- <div className="flex items-center gap-2 text-foreground">
14
- <Skeleton className="h-5 w-5 rounded bg-foreground/20" />
15
- <span className="text-sm font-bold tracking-tight">Ethos</span>
16
- </div>
17
- <div className="flex items-center gap-1">
18
- <Button variant="ghost" size="sm" className="text-muted-foreground font-medium h-8 px-3">Feedback</Button>
19
- <Button variant="ghost" size="sm" className="text-muted-foreground font-medium h-8 px-3">Docs</Button>
20
- <Button variant="ghost" size="sm" className="text-muted-foreground font-medium h-8 px-3 gap-1">
21
- <Sparkle size={14} />Ask
22
- </Button>
23
- <Separator orientation="vertical" className="h-4 mx-2" />
24
- <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
25
- <Bell size={16} />
26
- </Button>
27
- <Avatar className="h-7 w-7 cursor-pointer ml-1">
28
- <AvatarFallback className="bg-secondary text-foreground text-[11px] font-bold">U</AvatarFallback>
29
- </Avatar>
30
- </div>
31
- </header>
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
- * Only import in "use client" components β€” this module uses browser APIs.
7
- */
8
-
9
- export type Segment = {
10
- id: number
11
- speaker: string // e.g. "SPEAKER_00"
12
- start: number // seconds
13
- end: number // seconds
14
- text: string
15
- emotion: string
16
- valence: number
17
- arousal: number
18
- face_emotion?: string // FER result (video only): Anger | Contempt | Disgust | Fear | Happy | Neutral | Sad | Surprise
19
- }
20
-
21
- export type DiarizeResult = {
22
- segments: Segment[]
23
- duration: number
24
- text: string
25
- filename: string
26
- diarization_method?: string
27
- has_video?: boolean // true when FER was performed on video frames
28
- }
29
-
30
- type SessionEntry = {
31
- data: DiarizeResult
32
- audioUrl: string // blob: URL β€” valid for this browser session
33
- filename: string
34
- }
35
-
36
- // In-memory store (lives as long as the page/tab is open)
37
- const _store = new Map<string, SessionEntry>()
38
-
39
- function _sessionKey(id: string) {
40
- return `ser-session:${id}`
41
- }
42
-
43
- /** Store a new session; returns the session ID. */
44
- export function createSession(file: File, data: DiarizeResult): string {
45
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
46
- const audioUrl = URL.createObjectURL(file)
47
- _store.set(id, { data, audioUrl, filename: file.name })
48
-
49
- // Persist JSON to sessionStorage for resilience across navigation
50
- try {
51
- sessionStorage.setItem(_sessionKey(id), JSON.stringify({ data, filename: file.name }))
52
- } catch {
53
- // sessionStorage quota exceeded β€” in-memory only
54
- }
55
-
56
- return id
57
- }
58
-
59
- /** Retrieve a session. Returns null if not found. */
60
- export function getSession(id: string): SessionEntry | null {
61
- if (!id) return null
62
-
63
- // 1. Try in-memory (audio URL still valid)
64
- const entry = _store.get(id)
65
- if (entry) return entry
66
-
67
- // 2. Fall back to sessionStorage (no audio URL after page reload)
68
- try {
69
- const raw = sessionStorage.getItem(_sessionKey(id))
70
- if (raw) {
71
- const { data, filename } = JSON.parse(raw) as { data: DiarizeResult; filename: string }
72
- return { data, audioUrl: "", filename }
73
- }
74
- } catch {
75
- // ignore parse errors
76
- }
77
-
78
- return null
79
- }
80
-
81
- /** Release resources for a session. */
82
- export function clearSession(id: string) {
83
- const entry = _store.get(id)
84
- if (entry?.audioUrl) {
85
- URL.revokeObjectURL(entry.audioUrl)
86
- }
87
- _store.delete(id)
88
- try {
89
- sessionStorage.removeItem(_sessionKey(id))
90
- } catch {
91
- // ignore
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
+ }