Spaces:
Runtime error
Runtime error
Upload 49 files
Browse files- DEPLOY.md +160 -0
- DEPLOY_HF.md +49 -0
- Dockerfile +46 -0
- README.md +119 -5
- SPACE_README.md +29 -0
- eslint.config.js +31 -0
- index.html +13 -0
- package-lock.json +0 -0
- package.json +38 -0
- postcss.config.js +6 -0
- render.yaml +17 -0
- runtime.txt +1 -0
- server/README.md +92 -0
- server/__init__.py +1 -0
- server/app.py +433 -0
- server/config.py +32 -0
- server/providers/__init__.py +1 -0
- server/providers/base.py +19 -0
- server/providers/hf_inference.py +47 -0
- server/providers/marian.py +139 -0
- server/requirements-stt.txt +9 -0
- server/requirements.txt +15 -0
- server/test_app.py +42 -0
- server/translation_backend_masterpiece.py +241 -0
- src/App.tsx +83 -0
- src/components/Auth.tsx +106 -0
- src/components/CreateGroup.tsx +159 -0
- src/components/Dashboard.tsx +74 -0
- src/components/HostView.tsx +740 -0
- src/components/JoinGroup.tsx +100 -0
- src/components/MyGroups.tsx +125 -0
- src/components/Navigation.tsx +30 -0
- src/components/NavigationHooks.ts +18 -0
- src/components/QRCodeDisplay.tsx +45 -0
- src/components/ViewerView.tsx +273 -0
- src/contexts/AuthContext.tsx +79 -0
- src/contexts/useAuth.ts +10 -0
- src/index.css +3 -0
- src/lib/api.ts +92 -0
- src/lib/localdb.ts +399 -0
- src/lib/supabase.ts +52 -0
- src/main.tsx +10 -0
- src/useAuth.ts +10 -0
- src/vite-env.d.ts +1 -0
- tailwind.config.js +8 -0
- tsconfig.app.json +24 -0
- tsconfig.json +7 -0
- tsconfig.node.json +22 -0
- vite.config.ts +10 -0
DEPLOY.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploy Guide: Render (Backend) + Vercel (Frontend)
|
| 2 |
+
|
| 3 |
+
A complete, copy-paste guide to launch this project using free tiers.
|
| 4 |
+
|
| 5 |
+
- Frontend: React + Vite → Vercel (static site)
|
| 6 |
+
- Backend: FastAPI + Transformers → Render (free web service)
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1) Prerequisites
|
| 11 |
+
|
| 12 |
+
- GitHub repo: https://github.com/rashid714/updated-translator
|
| 13 |
+
- Accounts: Render (free), Vercel (free)
|
| 14 |
+
|
| 15 |
+
Optional (local dev): Node.js 18+, Python 3.11 or 3.13
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 2) Local run (optional)
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
# Python env
|
| 23 |
+
python3 -m venv .venv
|
| 24 |
+
source .venv/bin/activate
|
| 25 |
+
pip install -r server/requirements.txt
|
| 26 |
+
|
| 27 |
+
# Frontend deps
|
| 28 |
+
npm install
|
| 29 |
+
|
| 30 |
+
# Start both (Vite + FastAPI)
|
| 31 |
+
npm run dev:all
|
| 32 |
+
# Vite: http://localhost:5174
|
| 33 |
+
# API: http://127.0.0.1:8000 (GET /health)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Tip: In Host view, click "Preload Model" once to warm the translator.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 3) Backend on Render (Free)
|
| 41 |
+
|
| 42 |
+
Render builds and hosts the FastAPI app. Free plan sleeps when idle; next request wakes it.
|
| 43 |
+
|
| 44 |
+
Two choices:
|
| 45 |
+
|
| 46 |
+
### A) Blueprint (recommended)
|
| 47 |
+
|
| 48 |
+
This repo includes `render.yaml` and `runtime.txt`.
|
| 49 |
+
|
| 50 |
+
1. Render → New → Blueprint → Connect repo → choose `updated-translator` → Apply
|
| 51 |
+
2. Wait for deploy. First translation may take longer while weights download.
|
| 52 |
+
|
| 53 |
+
What it sets:
|
| 54 |
+
- Build: `pip install -r server/requirements.txt`
|
| 55 |
+
- Start: `uvicorn server.app:app --host 0.0.0.0 --port $PORT`
|
| 56 |
+
- Health Check: `/health`
|
| 57 |
+
- Python: 3.11.9 (via runtime.txt)
|
| 58 |
+
|
| 59 |
+
### B) Manual Web Service
|
| 60 |
+
|
| 61 |
+
1. New → Web Service → connect repo → Branch `main`
|
| 62 |
+
2. Root: `.` | Runtime: Python
|
| 63 |
+
3. Build:
|
| 64 |
+
```bash
|
| 65 |
+
pip install -r server/requirements.txt
|
| 66 |
+
```
|
| 67 |
+
4. Start:
|
| 68 |
+
```bash
|
| 69 |
+
uvicorn server.app:app --host 0.0.0.0 --port $PORT
|
| 70 |
+
```
|
| 71 |
+
5. Health Check Path: `/health` | Plan: Free
|
| 72 |
+
|
| 73 |
+
After deploy, test:
|
| 74 |
+
```bash
|
| 75 |
+
curl -sS https://<service>.onrender.com/health
|
| 76 |
+
# -> {"status":"ok"}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Notes:
|
| 80 |
+
- Cold start: free services sleep when idle; first request wakes them.
|
| 81 |
+
- First translation after deploy may download model weights; subsequent calls are fast.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## 4) Frontend on Vercel (Free)
|
| 86 |
+
|
| 87 |
+
1. New Project → Import GitHub → `updated-translator`
|
| 88 |
+
2. Framework: Vite
|
| 89 |
+
3. Build Command: `npm run build` | Output: `dist`
|
| 90 |
+
4. Environment Variables:
|
| 91 |
+
- `VITE_API_URL = https://<service>.onrender.com`
|
| 92 |
+
5. Deploy. Open your Vercel URL (e.g., `https://updated-translator.vercel.app`).
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## 5) Troubleshooting
|
| 97 |
+
|
| 98 |
+
- Tokenizers/torch build errors on Render
|
| 99 |
+
- Keep Python 3.11 (runtime.txt). `server/requirements.txt` pins versions with prebuilt wheels.
|
| 100 |
+
|
| 101 |
+
- 502/timeout on first call
|
| 102 |
+
- Likely cold start / first-time model download. Wait, then retry. Use “Preload Model”.
|
| 103 |
+
|
| 104 |
+
- CORS errors
|
| 105 |
+
- `server/app.py` enables permissive CORS for demo. If you tighten it, add your Vercel domain.
|
| 106 |
+
|
| 107 |
+
- Speech recording not working
|
| 108 |
+
- Some browsers don’t support Web Speech API. Use Manual Input fallback.
|
| 109 |
+
|
| 110 |
+
- Duplicate translation shown
|
| 111 |
+
- App suppresses translations equal to the original and avoids saving duplicates.
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 6) Optional: Single-service Docker on Render (frontend + API + Whisper)
|
| 116 |
+
|
| 117 |
+
If you prefer one Render service that serves both the UI and the API (and enables Whisper STT), switch to the Docker blueprint:
|
| 118 |
+
|
| 119 |
+
1. Ensure your Render blueprint `render.yaml` has:
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
services:
|
| 123 |
+
- type: web
|
| 124 |
+
name: updated-translator
|
| 125 |
+
env: docker
|
| 126 |
+
dockerfilePath: ./Dockerfile
|
| 127 |
+
healthCheckPath: /api/health
|
| 128 |
+
envVars:
|
| 129 |
+
- key: STT_ENABLED
|
| 130 |
+
value: "1"
|
| 131 |
+
- key: WHISPER_MODEL
|
| 132 |
+
value: "tiny"
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
2. Deploy via Render → New → Blueprint → select repo.
|
| 136 |
+
|
| 137 |
+
3. Verify:
|
| 138 |
+
|
| 139 |
+
```
|
| 140 |
+
curl -sS https://<service>.onrender.com/api/health
|
| 141 |
+
curl -sS https://<service>.onrender.com/api/provider
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Frontend will call same-origin `/api/*` automatically. Upload control for audio appears when STT is available.
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## 7) Useful endpoints
|
| 149 |
+
|
| 150 |
+
- `GET /api/health` → `{ "status": "ok" }`
|
| 151 |
+
- `POST /api/translate`
|
| 152 |
+
- Body: `{ "text": "...", "source_language": "ur", "target_language": "en" }`
|
| 153 |
+
- Response: `{ "translated_text": "..." }`
|
| 154 |
+
- `POST /api/preload`
|
| 155 |
+
- Body: `{ "source_language": "ur", "target_language": "en" }`
|
| 156 |
+
- Response: `{ "ok": true, "model": "Helsinki-NLP/opus-mt-ur-en" }`
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
That’s it—Render for the API, Vercel for the UI, both on free plans. If you prefer Hugging Face Spaces for the backend, I can add a Dockerfile-based Space setup as well.
|
DEPLOY_HF.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploy to Hugging Face Spaces (Docker)
|
| 2 |
+
|
| 3 |
+
This Space bundles the React frontend and FastAPI backend into a single container so you can share a link publicly for free.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
- Hugging Face account: https://huggingface.co/join
|
| 7 |
+
- Create a new Space (type: Docker)
|
| 8 |
+
|
| 9 |
+
## Files in this repo that matter
|
| 10 |
+
- `Dockerfile` – multi-stage build: builds the Vite app and runs FastAPI with Uvicorn
|
| 11 |
+
- `server/requirements.txt` – Python deps (Transformers, Torch, FastAPI, etc.)
|
| 12 |
+
- `server/app.py` – API (`/health`, `/translate`, `/preload`) and serves the built frontend
|
| 13 |
+
- `src/*` – React app; `src/lib/api.ts` defaults to same-origin API
|
| 14 |
+
|
| 15 |
+
## Steps
|
| 16 |
+
1. Create a new Space
|
| 17 |
+
- Choose "Docker" as SDK
|
| 18 |
+
- Name it, e.g., `your-username/live-translator`
|
| 19 |
+
2. Push the project files to the Space
|
| 20 |
+
- Copy this repository’s files into the Space or add it as a remote and push
|
| 21 |
+
- On the Space page, set the description by pasting the contents of `SPACE_README.md` from this repo into the Space README
|
| 22 |
+
3. Configure Space secrets and vars (optional but recommended)
|
| 23 |
+
- Core:
|
| 24 |
+
- `STT_ENABLED=1` – enable server-side transcription
|
| 25 |
+
- `WHISPER_MODEL=small` – CPU-friendly (use `medium` for better accuracy; `large-v3` requires GPU)
|
| 26 |
+
- Optional:
|
| 27 |
+
- `ALLOWED_ORIGINS` – comma-separated CORS origins (defaults to `*`)
|
| 28 |
+
- `MAX_TEXT_CHARS` – per-request limit (default `5000`)
|
| 29 |
+
- `REQUESTS_PER_MINUTE` – simple per-IP rate limit (default `60`)
|
| 30 |
+
- `TRANSLATION_PROVIDER` – `marian` (default) or `hf`
|
| 31 |
+
- `HUGGINGFACE_API_TOKEN` – required if using `TRANSLATION_PROVIDER=hf`
|
| 32 |
+
4. Build starts automatically
|
| 33 |
+
- The Space will build the Docker image and start the app on `$PORT` (7860 by default)
|
| 34 |
+
- The frontend is served from `/` and API is available at `/translate`, `/preload`, `/health`
|
| 35 |
+
- Metrics at `/metrics` (Prometheus format)
|
| 36 |
+
|
| 37 |
+
## Notes
|
| 38 |
+
- Initial requests may be slow while models download (cold start). Use the Preload button or call `/preload` to warm models.
|
| 39 |
+
- The free CPU tier is best-effort and may throttle under heavy traffic.
|
| 40 |
+
- For best transcription accuracy on free CPU, try `WHISPER_MODEL=medium` (slower). For maximum quality with lower latency, use a GPU Space and `large-v3`.
|
| 41 |
+
- For broader translation quality, consider the `hf` provider (e.g., NLLB-200), but expect higher latency on CPU.
|
| 42 |
+
|
| 43 |
+
## Try locally
|
| 44 |
+
```
|
| 45 |
+
# Build and run like HF Spaces would
|
| 46 |
+
docker build -t live-translator .
|
| 47 |
+
docker run -it --rm -p 7860:7860 live-translator
|
| 48 |
+
# then open http://localhost:7860
|
| 49 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage build for Hugging Face Spaces (Docker)
|
| 2 |
+
|
| 3 |
+
# --- Frontend build stage ---
|
| 4 |
+
FROM node:22-alpine AS frontend
|
| 5 |
+
WORKDIR /frontend
|
| 6 |
+
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
| 7 |
+
# Prefer npm; if lockfile missing, npm will still install using package.json
|
| 8 |
+
RUN npm ci || npm install
|
| 9 |
+
COPY . .
|
| 10 |
+
RUN npm run build
|
| 11 |
+
|
| 12 |
+
# --- Backend runtime stage ---
|
| 13 |
+
FROM python:3.11-slim AS runtime
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 15 |
+
PYTHONUNBUFFERED=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1 \
|
| 17 |
+
HF_HOME=/root/.cache/huggingface \
|
| 18 |
+
TRANSFORMERS_CACHE=/root/.cache/huggingface/transformers
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# System deps (git and curl handy for troubleshooting; remove if you want smaller image)
|
| 22 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 23 |
+
git curl ffmpeg && \
|
| 24 |
+
rm -rf /var/lib/apt/lists/*
|
| 25 |
+
|
| 26 |
+
# Install Python deps
|
| 27 |
+
COPY server/requirements.txt ./server/requirements.txt
|
| 28 |
+
RUN python -m pip install --upgrade pip && \
|
| 29 |
+
pip install --no-cache-dir -r server/requirements.txt
|
| 30 |
+
|
| 31 |
+
# Copy backend and built frontend
|
| 32 |
+
COPY server ./server
|
| 33 |
+
COPY --from=frontend /frontend/dist ./dist
|
| 34 |
+
|
| 35 |
+
# Preload all direct MarianMT models for instant demo reliability
|
| 36 |
+
RUN python3 -c "from transformers import pipeline; models=['Helsinki-NLP/opus-mt-en-hi','Helsinki-NLP/opus-mt-hi-en','Helsinki-NLP/opus-mt-en-zh','Helsinki-NLP/opus-mt-zh-en','Helsinki-NLP/opus-mt-en-ms','Helsinki-NLP/opus-mt-ms-en','Helsinki-NLP/opus-mt-en-id','Helsinki-NLP/opus-mt-id-en','Helsinki-NLP/opus-mt-en-ar','Helsinki-NLP/opus-mt-ar-en','Helsinki-NLP/opus-mt-en-ur','Helsinki-NLP/opus-mt-ur-en']; [pipeline('translation', model=m, device=-1) for m in models]"
|
| 37 |
+
|
| 38 |
+
# Optional: install STT dependencies (Whisper) to enable /transcribe when STT_ENABLED=1
|
| 39 |
+
RUN pip install --no-cache-dir -r server/requirements-stt.txt || true
|
| 40 |
+
|
| 41 |
+
# Default port for HF Spaces is provided via PORT env
|
| 42 |
+
ENV PORT=7860
|
| 43 |
+
EXPOSE 7860
|
| 44 |
+
|
| 45 |
+
# Use 0.0.0.0 to be externally reachable in container
|
| 46 |
+
CMD ["/bin/sh", "-lc", "python -m uvicorn server.app:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
CHANGED
|
@@ -1,10 +1,124 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Live Translator
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Live Translator (FastAPI + React)
|
| 11 |
+
|
| 12 |
+
This Space serves a production build of the React frontend and a FastAPI backend in one container. No external API tokens are required. Models download on first use and are cached.
|
| 13 |
+
|
| 14 |
+
## How it works
|
| 15 |
+
- Frontend: Vite/React compiled to `dist/` (built in the Docker image)
|
| 16 |
+
- Backend: FastAPI (Uvicorn) exposes `/translate`, `/preload`, `/health` and serves the static `dist/`
|
| 17 |
+
- Same-origin requests in production, so no CORS/issues
|
| 18 |
+
|
| 19 |
+
## Endpoints
|
| 20 |
+
- `GET /health` – basic health
|
| 21 |
+
- `POST /translate` – { text, source_language, target_language }
|
| 22 |
+
- `POST /preload` – warm up models for a language pair
|
| 23 |
+
|
| 24 |
+
## Notes
|
| 25 |
+
- First translation for a pair may take up to ~60s while models download/warm.
|
| 26 |
+
- For Malay→English, the app auto-tries `ms/en`, `id/en`, and English-bridge fallbacks.
|
| 27 |
+
---
|
| 28 |
+
title: Live Translator (Docker)
|
| 29 |
+
emoji: 🗣️
|
| 30 |
+
colorFrom: blue
|
| 31 |
+
colorTo: indigo
|
| 32 |
+
sdk: docker
|
| 33 |
+
pinned: false
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
# Live Translation (Full Stack)
|
| 37 |
+
|
| 38 |
+
[](https://huggingface.co/new-space)
|
| 39 |
+
|
| 40 |
+
Use the button to create a Docker Space, then follow the quick steps below (or see DEPLOY_HF.md). You do not need Vercel when deploying on Spaces (the container serves frontend + backend together).
|
| 41 |
+
|
| 42 |
+
All-in-one app for live transcription and translation:
|
| 43 |
+
- Frontend: React + Vite (static files)
|
| 44 |
+
- Backend: FastAPI (translation, optional transcription, metrics)
|
| 45 |
+
- One Docker image serves both frontend and API
|
| 46 |
+
|
| 47 |
+
## Run locally (frontend only)
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
npm install
|
| 51 |
+
npm run dev
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## Build (frontend)
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
npm run build
|
| 58 |
+
```
|
| 59 |
+
The static output is generated in `dist/`.
|
| 60 |
+
|
| 61 |
+
## Zero-cost deploy (Hugging Face Spaces — Docker)
|
| 62 |
+
|
| 63 |
+
The Dockerfile builds the frontend and runs the FastAPI backend, serving both from one URL.
|
| 64 |
+
|
| 65 |
+
1) Create a new Space
|
| 66 |
+
- Type: Docker • Visibility: Public
|
| 67 |
+
|
| 68 |
+
2) Push this repo to the Space
|
| 69 |
+
- Ensure the root contains: `Dockerfile`, `server/`, `src/`, `index.html`, `vite.config.ts`.
|
| 70 |
+
|
| 71 |
+
3) Set variables (Space Settings → Variables)
|
| 72 |
+
- `STT_ENABLED=1`
|
| 73 |
+
- `WHISPER_MODEL=small` (CPU-friendly; use `medium` for better accuracy if latency is OK)
|
| 74 |
+
- Optional: `REQUESTS_PER_MINUTE=60`, `ALLOWED_ORIGINS=*`
|
| 75 |
+
|
| 76 |
+
4) Space page description
|
| 77 |
+
- Copy the contents of `SPACE_README.md` into your Space “README.md” (Settings → Files) so visitors see simple usage instructions.
|
| 78 |
+
|
| 79 |
+
4) Build & run
|
| 80 |
+
- Spaces will build and start the app. First requests download models.
|
| 81 |
+
- Use the Host view “Preload Model” to warm caches.
|
| 82 |
+
|
| 83 |
+
5) Use it
|
| 84 |
+
- Open your Space URL (frontend + API on the same origin).
|
| 85 |
+
- Host view: Start Recording (sentence-level), Upload audio (with Language hint), Preload model.
|
| 86 |
+
|
| 87 |
+
Metrics: GET `/metrics` (Prometheus format).
|
| 88 |
+
|
| 89 |
+
## Notes
|
| 90 |
+
- Routing uses URL hash (e.g., `#/host/123`), so no special rewrites are needed.
|
| 91 |
+
- To reset local data, clear these keys in DevTools > Application > Local Storage:
|
| 92 |
+
- `lt_groups`, `lt_group_members`, `lt_segments`
|
| 93 |
+
- Temporary auth bypass is enabled in `src/contexts/AuthContext.tsx` (toggle `BYPASS_AUTH`).
|
| 94 |
+
|
| 95 |
+
## Optional local translation service (Python)
|
| 96 |
+
|
| 97 |
+
For live translation using small MarianMT models, run a local FastAPI server:
|
| 98 |
+
|
| 99 |
+
1) Create and activate a virtual environment (recommended):
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
python3 -m venv .venv
|
| 103 |
+
source .venv/bin/activate
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
2) Install requirements and start the server:
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
pip install -r server/requirements.txt
|
| 110 |
+
uvicorn server.app:app --reload --host 127.0.0.1 --port 8000
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
3) Point the frontend to it by creating a `.env` file:
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
cp .env.example .env
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Supported pairs include en<->es, en<->fr, en<->de, en<->ar, en<->ur, en<->hi, en<->zh via Helsinki-NLP MarianMT, with robust Malay fallbacks.
|
| 120 |
+
If the server isn’t running or a pair isn’t supported, the app gracefully degrades.
|
| 121 |
+
|
| 122 |
+
## Alternative free split (Render + Vercel)
|
| 123 |
+
|
| 124 |
+
See `DEPLOY.md` for a step-by-step guide to host the API on Render and the frontend on Vercel. Set `VITE_API_URL` in Vercel to your Render service URL.
|
SPACE_README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Live Translation App
|
| 2 |
+
|
| 3 |
+
This Space serves both the frontend (React + Vite build) and backend (FastAPI) from a single Docker container.
|
| 4 |
+
|
| 5 |
+
## How to use
|
| 6 |
+
|
| 7 |
+
- Open the Space URL. The UI loads from `/` and the API is same-origin.
|
| 8 |
+
- Host view:
|
| 9 |
+
- Start Recording for sentence-level live transcription
|
| 10 |
+
- Upload audio for server-side transcription (use the Language hint for accuracy)
|
| 11 |
+
- Click "Preload Model" to warm the translator
|
| 12 |
+
|
| 13 |
+
## Endpoints
|
| 14 |
+
|
| 15 |
+
- `GET /health` → `{ "status": "ok" }`
|
| 16 |
+
- `POST /translate` → `{ text, source_language, target_language }`
|
| 17 |
+
- `POST /preload` → `{ source_language, target_language }`
|
| 18 |
+
- `GET /metrics` → Prometheus metrics
|
| 19 |
+
|
| 20 |
+
## Space variables (recommended)
|
| 21 |
+
|
| 22 |
+
- `STT_ENABLED=1` – enable server-side transcription
|
| 23 |
+
- `WHISPER_MODEL=small` – good for CPU (use `medium` for better accuracy; `large-v3` requires GPU)
|
| 24 |
+
- Optional: `REQUESTS_PER_MINUTE=60`, `ALLOWED_ORIGINS=*`
|
| 25 |
+
|
| 26 |
+
## Notes
|
| 27 |
+
|
| 28 |
+
- First requests may download models. Use Preload to warm up.
|
| 29 |
+
- For best accuracy on fast or unclear audio, set `WHISPER_MODEL=medium` (CPU) or upgrade to a GPU Space and use `large-v3`.
|
eslint.config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js';
|
| 2 |
+
import globals from 'globals';
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks';
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh';
|
| 5 |
+
import tseslint from 'typescript-eslint';
|
| 6 |
+
|
| 7 |
+
export default tseslint.config(
|
| 8 |
+
{ ignores: ['dist'] },
|
| 9 |
+
{
|
| 10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
languageOptions: {
|
| 13 |
+
ecmaVersion: 2020,
|
| 14 |
+
globals: globals.browser,
|
| 15 |
+
},
|
| 16 |
+
plugins: {
|
| 17 |
+
'react-hooks': reactHooks,
|
| 18 |
+
'react-refresh': reactRefresh,
|
| 19 |
+
},
|
| 20 |
+
rules: {
|
| 21 |
+
...reactHooks.configs.recommended.rules,
|
| 22 |
+
'react-refresh/only-export-components': [
|
| 23 |
+
'warn',
|
| 24 |
+
{ allowConstantExport: true },
|
| 25 |
+
],
|
| 26 |
+
// Relax strictness for demo speed: allow 'any' and ignore unused vars.
|
| 27 |
+
'@typescript-eslint/no-explicit-any': 'off',
|
| 28 |
+
'@typescript-eslint/no-unused-vars': 'off',
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
);
|
index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Real-time Transcription Translation Streaming</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "vite-react-typescript-starter",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"server": "sh -c 'PY=\"./.venv/bin/python\"; \"$PY\" -m uvicorn server.app:app --host 127.0.0.1 --port 8000'",
|
| 9 |
+
"dev:all": "concurrently -k -n WEB,API -c blue,magenta \"npm run dev\" \"npm run server\"",
|
| 10 |
+
"build": "vite build",
|
| 11 |
+
"lint": "eslint .",
|
| 12 |
+
"preview": "vite preview",
|
| 13 |
+
"typecheck": "tsc --noEmit -p tsconfig.app.json"
|
| 14 |
+
},
|
| 15 |
+
"dependencies": {
|
| 16 |
+
"@supabase/supabase-js": "^2.57.4",
|
| 17 |
+
"lucide-react": "^0.344.0",
|
| 18 |
+
"react": "^18.3.1",
|
| 19 |
+
"react-dom": "^18.3.1"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^9.9.1",
|
| 23 |
+
"@types/react": "^18.3.5",
|
| 24 |
+
"@types/react-dom": "^18.3.0",
|
| 25 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 26 |
+
"autoprefixer": "^10.4.18",
|
| 27 |
+
"concurrently": "^9.2.1",
|
| 28 |
+
"eslint": "^9.9.1",
|
| 29 |
+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
| 30 |
+
"eslint-plugin-react-refresh": "^0.4.11",
|
| 31 |
+
"globals": "^15.9.0",
|
| 32 |
+
"postcss": "^8.4.35",
|
| 33 |
+
"tailwindcss": "^3.4.1",
|
| 34 |
+
"typescript": "^5.5.3",
|
| 35 |
+
"typescript-eslint": "^8.3.0",
|
| 36 |
+
"vite": "^5.4.2"
|
| 37 |
+
}
|
| 38 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
render.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: updated-translator
|
| 4 |
+
env: docker
|
| 5 |
+
plan: free
|
| 6 |
+
dockerfilePath: ./Dockerfile
|
| 7 |
+
autoDeploy: true
|
| 8 |
+
healthCheckPath: /api/health
|
| 9 |
+
envVars:
|
| 10 |
+
- key: REQUESTS_PER_MINUTE
|
| 11 |
+
value: "60"
|
| 12 |
+
- key: MAX_TEXT_CHARS
|
| 13 |
+
value: "5000"
|
| 14 |
+
- key: STT_ENABLED
|
| 15 |
+
value: "1"
|
| 16 |
+
- key: WHISPER_MODEL
|
| 17 |
+
value: "tiny"
|
runtime.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
python-3.11.9
|
server/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend (FastAPI) — Local Dev and Optional STT
|
| 2 |
+
|
| 3 |
+
This backend powers translation and (optionally) speech transcription.
|
| 4 |
+
|
| 5 |
+
## Install (core)
|
| 6 |
+
|
| 7 |
+
Core features (translate, preload, health) work without system packages:
|
| 8 |
+
|
| 9 |
+
1. Create/activate your virtualenv
|
| 10 |
+
2. Install core dependencies:
|
| 11 |
+
|
| 12 |
+
```
|
| 13 |
+
pip install -r server/requirements.txt
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
## Optional: Enable Hugging Face Inference API fallback
|
| 18 |
+
|
| 19 |
+
If local Marian models are unavailable or restricted, you can enable cloud translation fallback for world-class reliability:
|
| 20 |
+
|
| 21 |
+
1. Get a Hugging Face API token: https://huggingface.co/settings/tokens
|
| 22 |
+
2. Set the token in your environment:
|
| 23 |
+
- For local dev: `export HUGGINGFACE_API_TOKEN=your_token_here`
|
| 24 |
+
- For Docker: add to your Docker run command:
|
| 25 |
+
`-e HUGGINGFACE_API_TOKEN=your_token_here`
|
| 26 |
+
3. The backend will automatically use the Hugging Face Inference API for translation if Marian is unavailable.
|
| 27 |
+
|
| 28 |
+
## Optional: Enable Speech-to-Text (STT)
|
| 29 |
+
|
| 30 |
+
Two options:
|
| 31 |
+
|
| 32 |
+
1) Production (recommended): Docker/Hugging Face Space
|
| 33 |
+
|
| 34 |
+
- The provided `Dockerfile` installs FFmpeg and STT deps and sets `STT_ENABLED=1`.
|
| 35 |
+
- Build and run:
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
docker build -t live-translate .
|
| 39 |
+
docker run -p 7860:7860 -e PORT=7860 live-translate
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
2) Native macOS dev
|
| 43 |
+
|
| 44 |
+
- Install Homebrew and system prereqs:
|
| 45 |
+
- `brew install pkg-config ffmpeg`
|
| 46 |
+
- Install Python deps:
|
| 47 |
+
- `pip install -r server/requirements-stt.txt`
|
| 48 |
+
- Enable the endpoint:
|
| 49 |
+
- `export STT_ENABLED=1`
|
| 50 |
+
|
| 51 |
+
When STT is disabled, `/transcribe` returns 503 and the UI hides the upload control.
|
| 52 |
+
|
| 53 |
+
Whisper model size:
|
| 54 |
+
|
| 55 |
+
- Control via `WHISPER_MODEL` env: `tiny`, `base`, `small`, `medium`, `large-v3`.
|
| 56 |
+
- In Docker we default to `medium` for better accuracy. You can override:
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
docker run -p 7860:7860 -e PORT=7860 -e WHISPER_MODEL=small live-translate
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Run
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
uvicorn server.app:app --host 127.0.0.1 --port 8000 --reload
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Health check:
|
| 69 |
+
|
| 70 |
+
- GET http://127.0.0.1:8000/health => `{ "status": "ok" }`
|
| 71 |
+
|
| 72 |
+
## Metrics (optional)
|
| 73 |
+
|
| 74 |
+
If `prometheus-client` is installed (it is in `requirements.txt`), the API exposes Prometheus metrics at:
|
| 75 |
+
|
| 76 |
+
- GET http://127.0.0.1:8000/metrics (content type `text/plain; version=0.0.4`)
|
| 77 |
+
|
| 78 |
+
Includes:
|
| 79 |
+
|
| 80 |
+
- `http_requests_total{method,path,status}` and `http_request_duration_seconds{method,path}`
|
| 81 |
+
- `translations_total{src,tgt,status}` and `translation_duration_seconds{src,tgt}`
|
| 82 |
+
- `preload_total{src,tgt,status}` and `preload_duration_seconds{src,tgt}`
|
| 83 |
+
- `transcribe_total{status}` and `transcribe_duration_seconds`
|
| 84 |
+
|
| 85 |
+
## Frontend tip
|
| 86 |
+
|
| 87 |
+
In the Host view, you can select a “Language hint” for audio uploads. Choosing the correct source language improves transcription on fast or unclear speech.
|
| 88 |
+
|
| 89 |
+
## Notes
|
| 90 |
+
|
| 91 |
+
- In production (e.g., Docker or HF Spaces), the image includes FFmpeg so STT can be enabled there without extra steps.
|
| 92 |
+
- If you prefer not to install Homebrew locally, skip STT; the frontend falls back to browser SpeechRecognition and manual input.
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Make 'server' a package for reliable relative imports and better editor support.
|
server/app.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form, WebSocket, WebSocketDisconnect
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
from fastapi.responses import PlainTextResponse
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
from typing import Optional, Tuple, List, Dict, Any
|
| 11 |
+
import tempfile
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from .config import settings
|
| 15 |
+
from .providers.base import TranslationProvider
|
| 16 |
+
from .providers.marian import MarianProvider
|
| 17 |
+
try:
|
| 18 |
+
from langdetect import detect # lightweight auto language detection
|
| 19 |
+
except Exception: # pragma: no cover
|
| 20 |
+
detect = None # type: ignore
|
| 21 |
+
|
| 22 |
+
app = FastAPI(title="Live Translation API", version="2.0")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Prometheus metrics (optional)
|
| 26 |
+
try: # pragma: no cover - optional dependency
|
| 27 |
+
from prometheus_client import Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest
|
| 28 |
+
METRICS_ENABLED = True
|
| 29 |
+
# HTTP level metrics
|
| 30 |
+
HTTP_REQUESTS = Counter(
|
| 31 |
+
"http_requests_total",
|
| 32 |
+
"Total HTTP requests",
|
| 33 |
+
labelnames=("method", "path", "status"),
|
| 34 |
+
)
|
| 35 |
+
HTTP_REQUEST_DURATION = Histogram(
|
| 36 |
+
"http_request_duration_seconds",
|
| 37 |
+
"HTTP request duration in seconds",
|
| 38 |
+
labelnames=("method", "path"),
|
| 39 |
+
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
|
| 40 |
+
)
|
| 41 |
+
# Domain-specific metrics
|
| 42 |
+
TRANSLATIONS_TOTAL = Counter(
|
| 43 |
+
"translations_total",
|
| 44 |
+
"Number of translation requests",
|
| 45 |
+
labelnames=("src", "tgt", "status"),
|
| 46 |
+
)
|
| 47 |
+
TRANSLATION_DURATION = Histogram(
|
| 48 |
+
"translation_duration_seconds",
|
| 49 |
+
"Translation latency in seconds",
|
| 50 |
+
labelnames=("src", "tgt"),
|
| 51 |
+
buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
|
| 52 |
+
)
|
| 53 |
+
PRELOAD_TOTAL = Counter(
|
| 54 |
+
"preload_total",
|
| 55 |
+
"Number of model preload requests",
|
| 56 |
+
labelnames=("src", "tgt", "status"),
|
| 57 |
+
)
|
| 58 |
+
PRELOAD_DURATION = Histogram(
|
| 59 |
+
"preload_duration_seconds",
|
| 60 |
+
"Model preload latency in seconds",
|
| 61 |
+
labelnames=("src", "tgt"),
|
| 62 |
+
buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, float("inf")),
|
| 63 |
+
)
|
| 64 |
+
TRANSCRIBE_TOTAL = Counter(
|
| 65 |
+
"transcribe_total",
|
| 66 |
+
"Number of audio transcription requests",
|
| 67 |
+
labelnames=("status",),
|
| 68 |
+
)
|
| 69 |
+
TRANSCRIBE_DURATION = Histogram(
|
| 70 |
+
"transcribe_duration_seconds",
|
| 71 |
+
"Transcription latency in seconds",
|
| 72 |
+
buckets=(0.25, 0.5, 1.0, 2.0, 5.0, 15.0, 30.0, float("inf")),
|
| 73 |
+
)
|
| 74 |
+
except Exception: # pragma: no cover - optional dependency
|
| 75 |
+
METRICS_ENABLED = False
|
| 76 |
+
Counter = Histogram = None # type: ignore
|
| 77 |
+
|
| 78 |
+
# Allow all origins for demo simplicity. Tighten for production.
|
| 79 |
+
# If using wildcard origins, disable credentials to satisfy CORS rules.
|
| 80 |
+
_allow_credentials = not (len(settings.allowed_origins) == 1 and settings.allowed_origins[0] == "*")
|
| 81 |
+
app.add_middleware(
|
| 82 |
+
CORSMiddleware,
|
| 83 |
+
allow_origins=settings.allowed_origins,
|
| 84 |
+
allow_credentials=_allow_credentials,
|
| 85 |
+
allow_methods=["*"],
|
| 86 |
+
allow_headers=["*"],
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.middleware("http")
|
| 91 |
+
async def _request_id_and_metrics(request: Request, call_next):
|
| 92 |
+
# Assign a request ID and collect HTTP metrics
|
| 93 |
+
request_id = uuid.uuid4().hex
|
| 94 |
+
request.state.request_id = request_id
|
| 95 |
+
method = request.method
|
| 96 |
+
# Use path template to avoid high-cardinality label values
|
| 97 |
+
route = request.scope.get("route")
|
| 98 |
+
path_tmpl = getattr(route, "path", request.url.path)
|
| 99 |
+
started = time.perf_counter()
|
| 100 |
+
status_code = 500
|
| 101 |
+
try:
|
| 102 |
+
response = await call_next(request)
|
| 103 |
+
status_code = response.status_code
|
| 104 |
+
response.headers["X-Request-ID"] = request_id
|
| 105 |
+
return response
|
| 106 |
+
except Exception:
|
| 107 |
+
status_code = 500
|
| 108 |
+
raise
|
| 109 |
+
finally:
|
| 110 |
+
if METRICS_ENABLED:
|
| 111 |
+
try:
|
| 112 |
+
HTTP_REQUESTS.labels(method=method, path=path_tmpl, status=str(status_code)).inc()
|
| 113 |
+
HTTP_REQUEST_DURATION.labels(method=method, path=path_tmpl).observe(time.perf_counter() - started)
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TranslateRequest(BaseModel):
|
| 119 |
+
text: str
|
| 120 |
+
source_language: str # use "auto" to enable auto-detect when available
|
| 121 |
+
target_language: str
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class PreloadRequest(BaseModel):
|
| 125 |
+
source_language: str
|
| 126 |
+
target_language: str
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _make_provider() -> TranslationProvider:
|
| 130 |
+
from server.config import settings
|
| 131 |
+
# Use Marian by default, fallback to Hugging Face Inference API if configured
|
| 132 |
+
try:
|
| 133 |
+
marian = MarianProvider()
|
| 134 |
+
except Exception:
|
| 135 |
+
marian = None
|
| 136 |
+
# Hugging Face Inference API fallback (if token provided)
|
| 137 |
+
hf_api_token = getattr(settings, "hf_api_token", None)
|
| 138 |
+
if hf_api_token:
|
| 139 |
+
try:
|
| 140 |
+
from server.providers.hf_inference import HFInferenceProvider
|
| 141 |
+
hf = HFInferenceProvider(api_token=hf_api_token)
|
| 142 |
+
except Exception:
|
| 143 |
+
hf = None
|
| 144 |
+
else:
|
| 145 |
+
hf = None
|
| 146 |
+
class RouterProvider(TranslationProvider):
|
| 147 |
+
def supports(self, src, tgt):
|
| 148 |
+
return True
|
| 149 |
+
def preload(self, src, tgt):
|
| 150 |
+
if marian and marian.supports(src, tgt):
|
| 151 |
+
return marian.preload(src, tgt)
|
| 152 |
+
if hf:
|
| 153 |
+
return hf.preload(src, tgt)
|
| 154 |
+
return False
|
| 155 |
+
def translate(self, text, src, tgt, max_length=None):
|
| 156 |
+
# Try Marian first
|
| 157 |
+
if marian:
|
| 158 |
+
try:
|
| 159 |
+
result = marian.translate(text, src, tgt, max_length)
|
| 160 |
+
if not result.startswith("[No model"):
|
| 161 |
+
return result
|
| 162 |
+
except Exception:
|
| 163 |
+
pass
|
| 164 |
+
# Fallback to Hugging Face Inference API
|
| 165 |
+
if hf:
|
| 166 |
+
try:
|
| 167 |
+
return hf.translate(text, src, tgt, max_length)
|
| 168 |
+
except Exception:
|
| 169 |
+
pass
|
| 170 |
+
return f"[No model for {src}->{tgt}] {text}"
|
| 171 |
+
return RouterProvider()
|
| 172 |
+
|
| 173 |
+
provider: TranslationProvider = _make_provider()
|
| 174 |
+
_whisper_model = None
|
| 175 |
+
# STT route is disabled for local dev by default to avoid system deps.
|
| 176 |
+
# Production images (Docker/HF Spaces) can re-enable it with full dependencies installed.
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@app.on_event("startup")
|
| 180 |
+
def _warmup_startup():
|
| 181 |
+
# Do warmup asynchronously so /health is fast
|
| 182 |
+
import threading
|
| 183 |
+
|
| 184 |
+
def _bg():
|
| 185 |
+
try:
|
| 186 |
+
provider.preload("ms", "en")
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
+
try:
|
| 190 |
+
provider.preload("en", "ms")
|
| 191 |
+
except Exception:
|
| 192 |
+
pass
|
| 193 |
+
try:
|
| 194 |
+
provider.preload("id", "en")
|
| 195 |
+
except Exception:
|
| 196 |
+
pass
|
| 197 |
+
try:
|
| 198 |
+
provider.preload("en", "id")
|
| 199 |
+
except Exception:
|
| 200 |
+
pass
|
| 201 |
+
try:
|
| 202 |
+
provider.preload("mul", "en")
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
try:
|
| 206 |
+
provider.preload("en", "mul")
|
| 207 |
+
except Exception:
|
| 208 |
+
pass
|
| 209 |
+
try:
|
| 210 |
+
provider.preload("ur", "en")
|
| 211 |
+
except Exception:
|
| 212 |
+
pass
|
| 213 |
+
# Lazy import faster_whisper
|
| 214 |
+
global _whisper_model
|
| 215 |
+
try:
|
| 216 |
+
from faster_whisper import WhisperModel # type: ignore
|
| 217 |
+
_whisper_model = WhisperModel(settings.whisper_model, device="cpu")
|
| 218 |
+
except Exception:
|
| 219 |
+
_whisper_model = None
|
| 220 |
+
|
| 221 |
+
threading.Thread(target=_bg, name="warmup", daemon=True).start()
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class _RateLimiter:
|
| 226 |
+
# Very small in-memory rate limiter; good enough for demo on a single instance
|
| 227 |
+
def __init__(self, rpm: int) -> None:
|
| 228 |
+
self.rpm = max(1, rpm)
|
| 229 |
+
self._hits: Dict[str, List[float]] = {}
|
| 230 |
+
|
| 231 |
+
def allow(self, key: str) -> bool:
|
| 232 |
+
now = time.time()
|
| 233 |
+
window = now - 60.0
|
| 234 |
+
q = self._hits.setdefault(key, [])
|
| 235 |
+
# Drop old
|
| 236 |
+
while q and q[0] < window:
|
| 237 |
+
q.pop(0)
|
| 238 |
+
if len(q) >= self.rpm:
|
| 239 |
+
return False
|
| 240 |
+
q.append(now)
|
| 241 |
+
return True
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
_limiter = _RateLimiter(settings.requests_per_minute)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@app.get("/health")
|
| 248 |
+
@app.get("/api/health")
|
| 249 |
+
def health():
|
| 250 |
+
return {"status": "ok"}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@app.get("/provider")
|
| 254 |
+
@app.get("/api/provider")
|
| 255 |
+
def provider_info():
|
| 256 |
+
# Expose which translation backend is active for debugging
|
| 257 |
+
from server.config import settings as _s
|
| 258 |
+
return {"provider": _s.provider}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
if METRICS_ENABLED:
|
| 262 |
+
@app.get("/metrics")
|
| 263 |
+
@app.get("/api/metrics")
|
| 264 |
+
def metrics(): # pragma: no cover - simple exposition
|
| 265 |
+
try:
|
| 266 |
+
payload = generate_latest()
|
| 267 |
+
return PlainTextResponse(payload, media_type=CONTENT_TYPE_LATEST)
|
| 268 |
+
except Exception as e:
|
| 269 |
+
raise HTTPException(status_code=500, detail=f"Metrics error: {e}")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@app.get("/stt_status")
|
| 273 |
+
@app.get("/api/stt_status")
|
| 274 |
+
def stt_status():
|
| 275 |
+
# Report if full STT is enabled (requires optional deps and flag)
|
| 276 |
+
available = False
|
| 277 |
+
try:
|
| 278 |
+
import importlib.util as _ils
|
| 279 |
+
has_multipart = _ils.find_spec("multipart") is not None
|
| 280 |
+
from server.config import settings as _s
|
| 281 |
+
available = bool(has_multipart and _s.stt_enabled)
|
| 282 |
+
except Exception:
|
| 283 |
+
available = False
|
| 284 |
+
return {"available": available}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@app.post("/translate")
|
| 288 |
+
@app.post("/api/translate")
|
| 289 |
+
def translate(req: TranslateRequest, request: Request):
|
| 290 |
+
# rudimentary rate limit per client
|
| 291 |
+
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
|
| 292 |
+
ip = ip.split(",")[0].strip()
|
| 293 |
+
if not _limiter.allow(f"tx:{ip}"):
|
| 294 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 295 |
+
text = req.text.strip()
|
| 296 |
+
src = req.source_language.lower()
|
| 297 |
+
tgt = req.target_language.lower()
|
| 298 |
+
|
| 299 |
+
# No translation needed
|
| 300 |
+
if not text:
|
| 301 |
+
return {"translated_text": ""}
|
| 302 |
+
if len(text) > settings.max_text_chars:
|
| 303 |
+
raise HTTPException(status_code=413, detail="Text too long")
|
| 304 |
+
if src == tgt and src != "auto":
|
| 305 |
+
return {"translated_text": text}
|
| 306 |
+
|
| 307 |
+
# Auto-detect source if requested and detector is available
|
| 308 |
+
if src == "auto" and detect is not None:
|
| 309 |
+
try:
|
| 310 |
+
src = detect(text) or src
|
| 311 |
+
except Exception:
|
| 312 |
+
pass
|
| 313 |
+
|
| 314 |
+
if src == tgt:
|
| 315 |
+
return {"translated_text": text}
|
| 316 |
+
|
| 317 |
+
if not provider.supports(src, tgt):
|
| 318 |
+
return {"translated_text": f"[No model for {src}->{tgt}] {text}"}
|
| 319 |
+
|
| 320 |
+
started = time.perf_counter()
|
| 321 |
+
_status_label = "ok"
|
| 322 |
+
try:
|
| 323 |
+
translated = provider.translate(text, src, tgt, max_length=max(64, len(text) + 8))
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logging.exception("translation failed: %s", e)
|
| 326 |
+
_status_label = "error"
|
| 327 |
+
raise HTTPException(status_code=500, detail="Translation error")
|
| 328 |
+
finally:
|
| 329 |
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
| 330 |
+
logging.info("translate %s->%s len=%d took=%.1fms", src, tgt, len(text), elapsed_ms)
|
| 331 |
+
if METRICS_ENABLED:
|
| 332 |
+
try:
|
| 333 |
+
TRANSLATIONS_TOTAL.labels(src=src, tgt=tgt, status=_status_label).inc()
|
| 334 |
+
TRANSLATION_DURATION.labels(src=src, tgt=tgt).observe(elapsed_ms / 1000.0)
|
| 335 |
+
except Exception:
|
| 336 |
+
pass
|
| 337 |
+
return {"translated_text": translated}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@app.post("/preload")
|
| 341 |
+
@app.post("/api/preload")
|
| 342 |
+
def preload(req: PreloadRequest, request: Request):
|
| 343 |
+
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
|
| 344 |
+
ip = ip.split(",")[0].strip()
|
| 345 |
+
if not _limiter.allow(f"pre:{ip}"):
|
| 346 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 347 |
+
src = req.source_language.lower()
|
| 348 |
+
tgt = req.target_language.lower()
|
| 349 |
+
if src == tgt:
|
| 350 |
+
# Nothing to preload if no translation required
|
| 351 |
+
return {"ok": True, "message": "no-op (src==tgt)"}
|
| 352 |
+
if not provider.supports(src, tgt):
|
| 353 |
+
return {"ok": False, "message": f"No model for {src}->{tgt}"}
|
| 354 |
+
started = time.perf_counter()
|
| 355 |
+
ok = provider.preload(src, tgt)
|
| 356 |
+
elapsed_s = time.perf_counter() - started
|
| 357 |
+
if METRICS_ENABLED:
|
| 358 |
+
try:
|
| 359 |
+
PRELOAD_TOTAL.labels(src=src, tgt=tgt, status="ok" if ok else "skip").inc()
|
| 360 |
+
PRELOAD_DURATION.labels(src=src, tgt=tgt).observe(elapsed_s)
|
| 361 |
+
except Exception:
|
| 362 |
+
pass
|
| 363 |
+
return {"ok": ok}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# Conditionally register STT endpoint
|
| 367 |
+
import importlib.util as _ils
|
| 368 |
+
_has_multipart = _ils.find_spec("multipart") is not None
|
| 369 |
+
if settings.stt_enabled and _has_multipart:
|
| 370 |
+
@app.post("/transcribe")
|
| 371 |
+
@app.post("/api/transcribe")
|
| 372 |
+
async def transcribe(request: Request, audio: UploadFile = File(...), language: Optional[str] = Form(None)):
|
| 373 |
+
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?")
|
| 374 |
+
ip = ip.split(",")[0].strip()
|
| 375 |
+
if not _limiter.allow(f"stt:{ip}"):
|
| 376 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 377 |
+
|
| 378 |
+
# Ensure we refer to the module-level cache
|
| 379 |
+
global _whisper_model
|
| 380 |
+
if _whisper_model is None:
|
| 381 |
+
try:
|
| 382 |
+
from faster_whisper import WhisperModel # type: ignore
|
| 383 |
+
_whisper_model = WhisperModel(settings.whisper_model, device="cpu")
|
| 384 |
+
except Exception as e:
|
| 385 |
+
raise HTTPException(status_code=500, detail=f"STT unavailable: {e}")
|
| 386 |
+
|
| 387 |
+
# Save to temp file because decoders may need seekable input
|
| 388 |
+
suffix = os.path.splitext(audio.filename or "input.wav")[1] or ".wav"
|
| 389 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 390 |
+
contents = await audio.read()
|
| 391 |
+
tmp.write(contents)
|
| 392 |
+
tmp_path = tmp.name
|
| 393 |
+
started = time.perf_counter()
|
| 394 |
+
try:
|
| 395 |
+
segments, info = _whisper_model.transcribe(tmp_path, language=language, vad_filter=True)
|
| 396 |
+
texts: List[str] = []
|
| 397 |
+
for seg in segments:
|
| 398 |
+
texts.append(seg.text)
|
| 399 |
+
full_text = " ".join(t.strip() for t in texts if t and t.strip())
|
| 400 |
+
if METRICS_ENABLED:
|
| 401 |
+
try:
|
| 402 |
+
TRANSCRIBE_TOTAL.labels(status="ok").inc()
|
| 403 |
+
TRANSCRIBE_DURATION.observe(time.perf_counter() - started)
|
| 404 |
+
except Exception:
|
| 405 |
+
pass
|
| 406 |
+
return {"text": full_text, "language": getattr(info, 'language', None)}
|
| 407 |
+
except Exception as e:
|
| 408 |
+
if METRICS_ENABLED:
|
| 409 |
+
try:
|
| 410 |
+
TRANSCRIBE_TOTAL.labels(status="error").inc()
|
| 411 |
+
TRANSCRIBE_DURATION.observe(time.perf_counter() - started)
|
| 412 |
+
except Exception:
|
| 413 |
+
pass
|
| 414 |
+
raise HTTPException(status_code=500, detail=f"Transcription error: {e}")
|
| 415 |
+
finally:
|
| 416 |
+
try:
|
| 417 |
+
os.unlink(tmp_path)
|
| 418 |
+
except Exception:
|
| 419 |
+
pass
|
| 420 |
+
else:
|
| 421 |
+
@app.post("/transcribe")
|
| 422 |
+
@app.post("/api/transcribe")
|
| 423 |
+
async def transcribe_unavailable(request: Request):
|
| 424 |
+
raise HTTPException(status_code=503, detail="STT disabled. Enable optional deps and set STT_ENABLED=1.")
|
| 425 |
+
|
| 426 |
+
# Serve built frontend (Vite `dist`) if present
|
| 427 |
+
try:
|
| 428 |
+
import os
|
| 429 |
+
DIST_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dist"))
|
| 430 |
+
if os.path.isdir(DIST_DIR):
|
| 431 |
+
app.mount("/", StaticFiles(directory=DIST_DIR, html=True), name="static")
|
| 432 |
+
except Exception:
|
| 433 |
+
pass
|
server/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class Settings:
|
| 8 |
+
# Use local Marian provider by default (no external Inference API required)
|
| 9 |
+
provider: str = os.getenv("TRANSLATION_PROVIDER", "marian").lower()
|
| 10 |
+
# For Hugging Face Inference API
|
| 11 |
+
hf_api_token: str | None = os.getenv("HUGGINGFACE_API_TOKEN")
|
| 12 |
+
# Default timeout (seconds) for outbound HTTP calls
|
| 13 |
+
http_timeout: float = float(os.getenv("HTTP_TIMEOUT", "30"))
|
| 14 |
+
# Comma-separated list of allowed origins for CORS (production should not be "*")
|
| 15 |
+
allowed_origins: List[str] = field(
|
| 16 |
+
default_factory=lambda: (
|
| 17 |
+
[o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",")]
|
| 18 |
+
if os.getenv("ALLOWED_ORIGINS")
|
| 19 |
+
else ["*"]
|
| 20 |
+
)
|
| 21 |
+
)
|
| 22 |
+
# Max input characters per request to protect the service
|
| 23 |
+
max_text_chars: int = int(os.getenv("MAX_TEXT_CHARS", "5000"))
|
| 24 |
+
# Simple in-memory rate limit: requests per minute per client
|
| 25 |
+
requests_per_minute: int = int(os.getenv("REQUESTS_PER_MINUTE", "60"))
|
| 26 |
+
# Whisper model for speech-to-text (tiny, base, small; tiny/base recommended for CPU)
|
| 27 |
+
whisper_model: str = os.getenv("WHISPER_MODEL", "tiny")
|
| 28 |
+
# Toggle to enable /transcribe endpoint (requires optional deps). Defaults to off for easy local dev.
|
| 29 |
+
stt_enabled: bool = os.getenv("STT_ENABLED", "0").strip().lower() in {"1", "true", "yes", "on"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
settings = Settings()
|
server/providers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Providers package marker for reliable imports and editor analysis.
|
server/providers/base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TranslationProvider(ABC):
|
| 7 |
+
"""Abstract interface for translation providers."""
|
| 8 |
+
|
| 9 |
+
@abstractmethod
|
| 10 |
+
def supports(self, src: str, tgt: str) -> bool:
|
| 11 |
+
...
|
| 12 |
+
|
| 13 |
+
@abstractmethod
|
| 14 |
+
def preload(self, src: str, tgt: str) -> bool:
|
| 15 |
+
...
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def translate(self, text: str, src: str, tgt: str, max_length: Optional[int] = None) -> str:
|
| 19 |
+
...
|
server/providers/hf_inference.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from .base import TranslationProvider
|
| 3 |
+
|
| 4 |
+
class HFInferenceProvider(TranslationProvider):
|
| 5 |
+
def __init__(self, api_token: str):
|
| 6 |
+
self.api_token = api_token
|
| 7 |
+
# Use a more reliable endpoint for Malay/Indonesian to English
|
| 8 |
+
self.endpoints = {
|
| 9 |
+
("ms", "en"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-zsm-en",
|
| 10 |
+
("id", "en"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-id-en",
|
| 11 |
+
("en", "ms"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-zsm",
|
| 12 |
+
("en", "id"): "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-id",
|
| 13 |
+
}
|
| 14 |
+
self.default_endpoint = "https://api-inference.huggingface.co/models/Helsinki-NLP/opus-mt-en-mul"
|
| 15 |
+
|
| 16 |
+
def supports(self, src: str, tgt: str) -> bool:
|
| 17 |
+
return True
|
| 18 |
+
|
| 19 |
+
def preload(self, src: str, tgt: str) -> bool:
|
| 20 |
+
# No-op for cloud
|
| 21 |
+
return True
|
| 22 |
+
|
| 23 |
+
def translate(self, text: str, src: str, tgt: str, max_length=None) -> str:
|
| 24 |
+
headers = {"Authorization": f"Bearer {self.api_token}"}
|
| 25 |
+
payload = {"inputs": text}
|
| 26 |
+
endpoint = self.endpoints.get((src, tgt), self.default_endpoint)
|
| 27 |
+
try:
|
| 28 |
+
resp = requests.post(endpoint, headers=headers, json=payload, timeout=30)
|
| 29 |
+
resp.raise_for_status()
|
| 30 |
+
data = resp.json()
|
| 31 |
+
if isinstance(data, list) and data and "translation_text" in data[0]:
|
| 32 |
+
result = data[0]["translation_text"]
|
| 33 |
+
# If result is echo, try Indonesian fallback
|
| 34 |
+
if result.strip() == text.strip() and (src, tgt) == ("ms", "en"):
|
| 35 |
+
# Try Indonesian
|
| 36 |
+
ind_endpoint = self.endpoints.get(("id", "en"), self.default_endpoint)
|
| 37 |
+
resp2 = requests.post(ind_endpoint, headers=headers, json=payload, timeout=30)
|
| 38 |
+
resp2.raise_for_status()
|
| 39 |
+
data2 = resp2.json()
|
| 40 |
+
if isinstance(data2, list) and data2 and "translation_text" in data2[0]:
|
| 41 |
+
result2 = data2[0]["translation_text"]
|
| 42 |
+
if result2.strip() != text.strip():
|
| 43 |
+
return result2
|
| 44 |
+
return result
|
| 45 |
+
return str(data)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
return f"[HF error] {e}"
|
server/providers/marian.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
from typing import Dict, Optional, Tuple, List, Dict as TDict, Any, Iterable, cast
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from cachetools import LRUCache
|
| 7 |
+
from transformers.pipelines import pipeline
|
| 8 |
+
from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
|
| 9 |
+
|
| 10 |
+
from .base import TranslationProvider
|
| 11 |
+
|
| 12 |
+
# Map (src, tgt) -> model id (Helsinki-NLP MarianMT)
|
| 13 |
+
MODEL_MAP: Dict[Tuple[str, str], str] = {
|
| 14 |
+
("en", "es"): "Helsinki-NLP/opus-mt-en-es",
|
| 15 |
+
("es", "en"): "Helsinki-NLP/opus-mt-es-en",
|
| 16 |
+
("en", "fr"): "Helsinki-NLP/opus-mt-en-fr",
|
| 17 |
+
("fr", "en"): "Helsinki-NLP/opus-mt-fr-en",
|
| 18 |
+
("en", "de"): "Helsinki-NLP/opus-mt-en-de",
|
| 19 |
+
("de", "en"): "Helsinki-NLP/opus-mt-de-en",
|
| 20 |
+
("en", "ar"): "Helsinki-NLP/opus-mt-en-ar",
|
| 21 |
+
("ar", "en"): "Helsinki-NLP/opus-mt-ar-en",
|
| 22 |
+
("en", "ur"): "Helsinki-NLP/opus-mt-en-ur",
|
| 23 |
+
("ur", "en"): "Helsinki-NLP/opus-mt-ur-en",
|
| 24 |
+
("en", "hi"): "Helsinki-NLP/opus-mt-en-hi",
|
| 25 |
+
("hi", "en"): "Helsinki-NLP/opus-mt-hi-en",
|
| 26 |
+
("en", "zh"): "Helsinki-NLP/opus-mt-en-zh",
|
| 27 |
+
("zh", "en"): "Helsinki-NLP/opus-mt-zh-en",
|
| 28 |
+
# Malay (often labeled zsm in OPUS) ⇄ English
|
| 29 |
+
("en", "ms"): "Helsinki-NLP/opus-mt-en-zsm",
|
| 30 |
+
("ms", "en"): "Helsinki-NLP/opus-mt-zsm-en",
|
| 31 |
+
# Indonesian
|
| 32 |
+
("en", "id"): "Helsinki-NLP/opus-mt-en-id",
|
| 33 |
+
("id", "en"): "Helsinki-NLP/opus-mt-id-en",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@lru_cache(maxsize=16)
|
| 38 |
+
def _get_pipeline(model_id: str) -> Text2TextGenerationPipeline:
|
| 39 |
+
return cast(Text2TextGenerationPipeline, pipeline("translation", model=model_id))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class MarianProvider(TranslationProvider):
|
| 43 |
+
# Simple in-memory cache for translation results to improve latency/cost
|
| 44 |
+
_cache: LRUCache = LRUCache(maxsize=int(os.getenv("TRANSLATION_CACHE_SIZE", "512")))
|
| 45 |
+
|
| 46 |
+
def _preload_any(self, src: str, tgt: str) -> bool:
|
| 47 |
+
for model_id in self._candidates(src, tgt):
|
| 48 |
+
try:
|
| 49 |
+
_ = _get_pipeline(model_id)
|
| 50 |
+
return True
|
| 51 |
+
except Exception:
|
| 52 |
+
continue
|
| 53 |
+
return False
|
| 54 |
+
def _candidates(self, src: str, tgt: str) -> Iterable[str]:
|
| 55 |
+
# 1) Explicit map
|
| 56 |
+
explicit = MODEL_MAP.get((src, tgt))
|
| 57 |
+
if explicit:
|
| 58 |
+
yield explicit
|
| 59 |
+
# 2) Dynamic guess
|
| 60 |
+
yield f"Helsinki-NLP/opus-mt-{src}-{tgt}"
|
| 61 |
+
# 3) Malay direct (zsm)
|
| 62 |
+
if src == "ms" and tgt == "en":
|
| 63 |
+
yield "Helsinki-NLP/opus-mt-zsm-en"
|
| 64 |
+
if src == "en" and tgt == "ms":
|
| 65 |
+
yield "Helsinki-NLP/opus-mt-en-zsm"
|
| 66 |
+
# 3b) Practical fallback: Indonesian models often work reasonably for Malay
|
| 67 |
+
if tgt == "en" and src == "ms":
|
| 68 |
+
yield "Helsinki-NLP/opus-mt-id-en"
|
| 69 |
+
if src == "en" and tgt == "ms":
|
| 70 |
+
yield "Helsinki-NLP/opus-mt-en-id"
|
| 71 |
+
# 4) Universal multilingual fallback when specific pair is missing
|
| 72 |
+
if tgt == "en":
|
| 73 |
+
yield "Helsinki-NLP/opus-mt-mul-en"
|
| 74 |
+
if src == "en":
|
| 75 |
+
yield "Helsinki-NLP/opus-mt-en-mul"
|
| 76 |
+
|
| 77 |
+
def supports(self, src: str, tgt: str) -> bool:
|
| 78 |
+
# Optimistically support and handle errors at translate/preload time.
|
| 79 |
+
return True
|
| 80 |
+
|
| 81 |
+
def preload(self, src: str, tgt: str) -> bool:
|
| 82 |
+
# Try direct pair first
|
| 83 |
+
if self._preload_any(src, tgt):
|
| 84 |
+
return True
|
| 85 |
+
# Bridge via English if direct model unavailable (src->en and en->tgt)
|
| 86 |
+
if src != "en" and tgt != "en":
|
| 87 |
+
a = self._preload_any(src, "en")
|
| 88 |
+
b = self._preload_any("en", tgt)
|
| 89 |
+
return a and b
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
def translate(self, text: str, src: str, tgt: str, max_length: Optional[int] = None) -> str:
|
| 93 |
+
# Try direct candidate models until one works
|
| 94 |
+
for mid in self._candidates(src, tgt):
|
| 95 |
+
cache_key = (mid, text)
|
| 96 |
+
cached = self._cache.get(cache_key)
|
| 97 |
+
if isinstance(cached, str):
|
| 98 |
+
return cached
|
| 99 |
+
try:
|
| 100 |
+
pipe = _get_pipeline(mid)
|
| 101 |
+
raw = pipe(text, max_length=max_length or max(64, len(text) + 8))
|
| 102 |
+
out = cast(List[TDict[str, Any]], raw)
|
| 103 |
+
first: TDict[str, Any] = out[0] if out else {"translation_text": ""}
|
| 104 |
+
result = str(first.get("translation_text", ""))
|
| 105 |
+
self._cache[cache_key] = result
|
| 106 |
+
return result
|
| 107 |
+
except Exception:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# Bridge via English if no direct model works
|
| 111 |
+
if src != "en" and tgt != "en":
|
| 112 |
+
# src -> en
|
| 113 |
+
mid1 = None
|
| 114 |
+
for c in self._candidates(src, "en"):
|
| 115 |
+
try:
|
| 116 |
+
mid1 = c
|
| 117 |
+
pipe1 = _get_pipeline(c)
|
| 118 |
+
raw1 = pipe1(text, max_length=max_length or max(64, len(text) + 8))
|
| 119 |
+
out1 = cast(List[TDict[str, Any]], raw1)
|
| 120 |
+
first1: TDict[str, Any] = out1[0] if out1 else {"translation_text": ""}
|
| 121 |
+
text_en = str(first1.get("translation_text", ""))
|
| 122 |
+
# en -> tgt
|
| 123 |
+
for c2 in self._candidates("en", tgt):
|
| 124 |
+
try:
|
| 125 |
+
pipe2 = _get_pipeline(c2)
|
| 126 |
+
raw2 = pipe2(text_en, max_length=max_length or max(64, len(text_en) + 8))
|
| 127 |
+
out2 = cast(List[TDict[str, Any]], raw2)
|
| 128 |
+
first2: TDict[str, Any] = out2[0] if out2 else {"translation_text": ""}
|
| 129 |
+
result = str(first2.get("translation_text", ""))
|
| 130 |
+
# cache using synthetic key to avoid giant cache miss costs
|
| 131 |
+
self._cache[(f"{mid1}|bridge", text)] = result
|
| 132 |
+
return result
|
| 133 |
+
except Exception:
|
| 134 |
+
continue
|
| 135 |
+
except Exception:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
# All attempts failed
|
| 139 |
+
return f"[No model for {src}->{tgt}] {text}"
|
server/requirements-stt.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optional STT (Speech-to-Text) dependencies
|
| 2 |
+
# Prerequisites (macOS):
|
| 3 |
+
# - Install Homebrew (https://brew.sh)
|
| 4 |
+
# - brew install pkg-config ffmpeg
|
| 5 |
+
# Then install these Python deps:
|
| 6 |
+
# pip install -r server/requirements-stt.txt
|
| 7 |
+
|
| 8 |
+
faster-whisper==1.0.3
|
| 9 |
+
python-multipart==0.0.9
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
# Newer Transformers uses tokenizers>=0.20 with wheels for Python 3.13
|
| 4 |
+
transformers==4.46.3
|
| 5 |
+
# PyTorch version compatible with Python 3.13 on macOS
|
| 6 |
+
torch==2.7.1
|
| 7 |
+
sentencepiece>=0.1.99
|
| 8 |
+
sacremoses>=0.1.1
|
| 9 |
+
cachetools==5.3.3
|
| 10 |
+
langdetect==1.0.9
|
| 11 |
+
# Metrics/observability
|
| 12 |
+
prometheus-client==0.20.0
|
| 13 |
+
# STT (speech-to-text) dependencies are optional for local dev on macOS.
|
| 14 |
+
# To enable STT locally, install prerequisites and then run:
|
| 15 |
+
# pip install -r server/requirements-stt.txt
|
server/test_app.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from server.app import app
|
| 4 |
+
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
def test_health():
|
| 8 |
+
resp = client.get("/health")
|
| 9 |
+
assert resp.status_code == 200
|
| 10 |
+
assert resp.json()["status"] == "ok"
|
| 11 |
+
|
| 12 |
+
def test_translate_basic():
|
| 13 |
+
data = {
|
| 14 |
+
"text": "Terima kasih banyak",
|
| 15 |
+
"source_language": "ms",
|
| 16 |
+
"target_language": "en"
|
| 17 |
+
}
|
| 18 |
+
resp = client.post("/translate", json=data)
|
| 19 |
+
assert resp.status_code == 200
|
| 20 |
+
assert "translated_text" in resp.json()
|
| 21 |
+
# Should not return error or echo
|
| 22 |
+
out = resp.json()["translated_text"]
|
| 23 |
+
assert out and not out.startswith("[No model") and out != data["text"]
|
| 24 |
+
|
| 25 |
+
def test_preload():
|
| 26 |
+
data = {
|
| 27 |
+
"source_language": "ms",
|
| 28 |
+
"target_language": "en"
|
| 29 |
+
}
|
| 30 |
+
resp = client.post("/preload", json=data)
|
| 31 |
+
assert resp.status_code == 200
|
| 32 |
+
assert resp.json()["ok"] is True
|
| 33 |
+
|
| 34 |
+
def test_stt_status():
|
| 35 |
+
resp = client.get("/stt_status")
|
| 36 |
+
assert resp.status_code == 200
|
| 37 |
+
assert "available" in resp.json()
|
| 38 |
+
|
| 39 |
+
def test_provider_info():
|
| 40 |
+
resp = client.get("/provider")
|
| 41 |
+
assert resp.status_code == 200
|
| 42 |
+
assert "provider" in resp.json()
|
server/translation_backend_masterpiece.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Translation Backend Masterpiece
|
| 3 |
+
- Preloads all required MarianMT models
|
| 4 |
+
- Handles all language pairs robustly
|
| 5 |
+
- Provides health/model endpoints
|
| 6 |
+
- Catches and logs all errors
|
| 7 |
+
- Uses GPU if available
|
| 8 |
+
- Caches models and translations
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from transformers import pipeline
|
| 14 |
+
import torch
|
| 15 |
+
from functools import lru_cache
|
| 16 |
+
from fastapi.responses import JSONResponse
|
| 17 |
+
import logging
|
| 18 |
+
from fastapi.responses import JSONResponse
|
| 19 |
+
from fastapi import Request
|
| 20 |
+
import time
|
| 21 |
+
from typing import Dict, Tuple
|
| 22 |
+
from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
|
| 23 |
+
import asyncio
|
| 24 |
+
|
| 25 |
+
app = FastAPI()
|
| 26 |
+
from collections import defaultdict
|
| 27 |
+
|
| 28 |
+
# Simple in-memory rate limiter (per IP)
|
| 29 |
+
RATE_LIMIT = 30 # max requests per minute
|
| 30 |
+
rate_limit_data = defaultdict(lambda: {'count': 0, 'reset': time.time() + 60})
|
| 31 |
+
|
| 32 |
+
def check_rate_limit(ip: str):
|
| 33 |
+
now = time.time()
|
| 34 |
+
data = rate_limit_data[ip]
|
| 35 |
+
if now > data['reset']:
|
| 36 |
+
data['count'] = 0
|
| 37 |
+
data['reset'] = now + 60
|
| 38 |
+
data['count'] += 1
|
| 39 |
+
if data['count'] > RATE_LIMIT:
|
| 40 |
+
return False
|
| 41 |
+
return True
|
| 42 |
+
import sys
|
| 43 |
+
logger = logging.getLogger("translation-backend")
|
| 44 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 45 |
+
formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s')
|
| 46 |
+
handler.setFormatter(formatter)
|
| 47 |
+
logger.addHandler(handler)
|
| 48 |
+
logger.setLevel(logging.INFO)
|
| 49 |
+
import time
|
| 50 |
+
from fastapi import Response
|
| 51 |
+
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
| 52 |
+
|
| 53 |
+
# Prometheus metrics for monitoring
|
| 54 |
+
TRANSLATION_COUNT = Counter('translation_requests_total', 'Total translation requests', ['pair', 'status'])
|
| 55 |
+
TRANSLATION_LATENCY = Histogram('translation_latency_seconds', 'Translation latency', ['pair'])
|
| 56 |
+
|
| 57 |
+
# List all language pairs you want to support
|
| 58 |
+
LANGUAGE_PAIRS = [
|
| 59 |
+
# ("en", "ta"), ("ta", "en"), # Tamil removed
|
| 60 |
+
("en", "hi"), ("hi", "en"),
|
| 61 |
+
("en", "zh"), ("zh", "en"),
|
| 62 |
+
("en", "ms"), ("ms", "en"),
|
| 63 |
+
("en", "id"), ("id", "en"),
|
| 64 |
+
("en", "ar"), ("ar", "en"), # Arabic
|
| 65 |
+
("en", "ur"), ("ur", "en"), # Urdu
|
| 66 |
+
# Add more pairs as needed
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
MODEL_NAMES = {
|
| 70 |
+
# ("en", "ta"): "ArunIcfoss/nllb-200-distilled-1.3B-ICFOSS-Tamil_Malayalam_Translation2",
|
| 71 |
+
# ("ta", "en"): "ArunIcfoss/nllb-200-distilled-1.3B-ICFOSS-Tamil_Malayalam_Translation2", # Tamil removed
|
| 72 |
+
("en", "hi"): "Helsinki-NLP/opus-mt-en-hi",
|
| 73 |
+
("hi", "en"): "Helsinki-NLP/opus-mt-hi-en",
|
| 74 |
+
("en", "zh"): "Helsinki-NLP/opus-mt-en-zh",
|
| 75 |
+
("zh", "en"): "Helsinki-NLP/opus-mt-zh-en",
|
| 76 |
+
("en", "ms"): "Helsinki-NLP/opus-mt-en-ms",
|
| 77 |
+
("ms", "en"): "Helsinki-NLP/opus-mt-ms-en",
|
| 78 |
+
("en", "id"): "Helsinki-NLP/opus-mt-en-id",
|
| 79 |
+
("id", "en"): "Helsinki-NLP/opus-mt-id-en",
|
| 80 |
+
("en", "ar"): "Helsinki-NLP/opus-mt-en-ar",
|
| 81 |
+
("ar", "en"): "Helsinki-NLP/opus-mt-ar-en",
|
| 82 |
+
("en", "ur"): "Helsinki-NLP/opus-mt-en-ur",
|
| 83 |
+
("ur", "en"): "Helsinki-NLP/opus-mt-ur-en",
|
| 84 |
+
# Add more pairs as needed
|
| 85 |
+
}
|
| 86 |
+
SUPPORTED_PAIRS = list(MODEL_NAMES.keys())
|
| 87 |
+
|
| 88 |
+
class TranslateRequest(BaseModel):
|
| 89 |
+
text: str
|
| 90 |
+
source_language: str
|
| 91 |
+
target_language: str
|
| 92 |
+
|
| 93 |
+
from transformers.pipelines.text2text_generation import Text2TextGenerationPipeline
|
| 94 |
+
# Preload all models at startup
|
| 95 |
+
MODELS: Dict[Tuple[str, str], Text2TextGenerationPipeline] = {}
|
| 96 |
+
|
| 97 |
+
# LRU cache for translations (demo reliability)
|
| 98 |
+
@lru_cache(maxsize=512)
|
| 99 |
+
def cached_translate(pair, text):
|
| 100 |
+
pipe = MODELS.get(pair)
|
| 101 |
+
if not pipe:
|
| 102 |
+
return None
|
| 103 |
+
result = pipe(text)
|
| 104 |
+
if not isinstance(result, list) or not result or not isinstance(result[0], dict) or "translation_text" not in result[0]:
|
| 105 |
+
return None
|
| 106 |
+
return result[0].get("translation_text")
|
| 107 |
+
DEVICE = 0 if torch.cuda.is_available() else -1
|
| 108 |
+
|
| 109 |
+
@app.on_event("startup")
|
| 110 |
+
def preload_models():
|
| 111 |
+
# Load and verify models, track health
|
| 112 |
+
global VERIFIED_PAIRS
|
| 113 |
+
VERIFIED_PAIRS = []
|
| 114 |
+
for pair, model_name in MODEL_NAMES.items():
|
| 115 |
+
try:
|
| 116 |
+
pipe = pipeline("translation", model=model_name, device=DEVICE)
|
| 117 |
+
if not isinstance(pipe, Text2TextGenerationPipeline):
|
| 118 |
+
raise Exception(f"Loaded pipeline is not Text2TextGenerationPipeline for {pair}")
|
| 119 |
+
test_result = pipe("Hello world")
|
| 120 |
+
if not isinstance(test_result, list) or not test_result or "translation_text" not in test_result[0]:
|
| 121 |
+
raise Exception(f"Model {model_name} for {pair} failed test translation.")
|
| 122 |
+
MODELS[pair] = pipe
|
| 123 |
+
VERIFIED_PAIRS.append(pair)
|
| 124 |
+
logger.info(f"Loaded and verified model for {pair}: {model_name}")
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"Failed to load or verify model {model_name} for {pair}: {e}")
|
| 127 |
+
|
| 128 |
+
@app.get("/health")
|
| 129 |
+
@app.get("/livez")
|
| 130 |
+
def livez():
|
| 131 |
+
"""Kubernetes-style liveness probe."""
|
| 132 |
+
return Response(content="ok", media_type="text/plain")
|
| 133 |
+
def health():
|
| 134 |
+
loaded = {str(pair): str(type(MODELS.get(pair))) for pair in SUPPORTED_PAIRS}
|
| 135 |
+
device_info = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu"
|
| 136 |
+
return {"models": loaded, "device": device_info}
|
| 137 |
+
|
| 138 |
+
# Prometheus metrics endpoint
|
| 139 |
+
@app.get("/metrics")
|
| 140 |
+
def metrics():
|
| 141 |
+
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
| 142 |
+
class BatchTranslateRequest(BaseModel):
|
| 143 |
+
source_lang: str
|
| 144 |
+
target_lang: str
|
| 145 |
+
texts: list[str]
|
| 146 |
+
|
| 147 |
+
@app.get("/models")
|
| 148 |
+
def models():
|
| 149 |
+
# Only show verified working pairs
|
| 150 |
+
return {"models": VERIFIED_PAIRS}
|
| 151 |
+
|
| 152 |
+
async def translate(req: TranslateRequest, request: Request):
|
| 153 |
+
ip = request.client.host if request.client else 'unknown'
|
| 154 |
+
if not check_rate_limit(ip):
|
| 155 |
+
return JSONResponse(status_code=429, content={
|
| 156 |
+
"error": "rate_limited",
|
| 157 |
+
"message": "Too many requests. Please wait and try again."
|
| 158 |
+
})
|
| 159 |
+
pair = (req.source_language, req.target_language)
|
| 160 |
+
start_time = time.time()
|
| 161 |
+
if pair not in MODELS:
|
| 162 |
+
# Two-step translation via English if direct model is missing
|
| 163 |
+
fallback = None
|
| 164 |
+
try:
|
| 165 |
+
mid = None
|
| 166 |
+
if req.source_language != "en" and (req.source_language, "en") in MODELS:
|
| 167 |
+
mid = cached_translate((req.source_language, "en"), req.text)
|
| 168 |
+
else:
|
| 169 |
+
mid = req.text
|
| 170 |
+
if mid and req.target_language != "en" and ("en", req.target_language) in MODELS:
|
| 171 |
+
fallback = cached_translate(("en", req.target_language), mid)
|
| 172 |
+
elif mid:
|
| 173 |
+
fallback = mid
|
| 174 |
+
except Exception:
|
| 175 |
+
fallback = None
|
| 176 |
+
logger.warning(f"Requested model {pair} not loaded. Two-step fallback used: {bool(fallback)}")
|
| 177 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="missing_model").inc()
|
| 178 |
+
return JSONResponse(status_code=200, content={
|
| 179 |
+
"translated_text": fallback,
|
| 180 |
+
"latency": time.time() - start_time,
|
| 181 |
+
"fallback": True,
|
| 182 |
+
"message": f"Model for {pair} not loaded. Used two-step translation via English.",
|
| 183 |
+
"supported_pairs": VERIFIED_PAIRS,
|
| 184 |
+
"timestamp": time.time(),
|
| 185 |
+
"ip": ip
|
| 186 |
+
})
|
| 187 |
+
try:
|
| 188 |
+
await asyncio.sleep(0)
|
| 189 |
+
translated = cached_translate(pair, req.text)
|
| 190 |
+
latency = time.time() - start_time
|
| 191 |
+
logger.info(f"Translation {pair}: '{req.text[:30]}...' -> '{str(translated)[:30]}...' | latency: {latency:.3f}s | ip: {ip}")
|
| 192 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="success").inc()
|
| 193 |
+
TRANSLATION_LATENCY.labels(pair=str(pair)).observe(latency)
|
| 194 |
+
if not translated:
|
| 195 |
+
raise Exception("No translation returned.")
|
| 196 |
+
return {"translated_text": translated, "latency": latency, "timestamp": time.time(), "ip": ip}
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Translation error for {pair}: {e} | ip: {ip}")
|
| 199 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="error").inc()
|
| 200 |
+
return JSONResponse(status_code=500, content={
|
| 201 |
+
"error": "translation_failed",
|
| 202 |
+
"message": str(e),
|
| 203 |
+
"timestamp": time.time(),
|
| 204 |
+
"ip": ip
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
# Batch translation endpoint for demo reliability
|
| 208 |
+
@app.post("/batch_translate")
|
| 209 |
+
@app.post("/batch_translate")
|
| 210 |
+
async def batch_translate(req: BatchTranslateRequest):
|
| 211 |
+
pair = (req.source_lang, req.target_lang)
|
| 212 |
+
if pair not in MODELS:
|
| 213 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="missing_model").inc()
|
| 214 |
+
return JSONResponse(status_code=400, content={
|
| 215 |
+
"error": "model_not_loaded",
|
| 216 |
+
"message": f"Model for {pair} not loaded.",
|
| 217 |
+
"supported_pairs": SUPPORTED_PAIRS
|
| 218 |
+
})
|
| 219 |
+
results = []
|
| 220 |
+
latencies = []
|
| 221 |
+
for text in req.texts:
|
| 222 |
+
start_time = time.time()
|
| 223 |
+
try:
|
| 224 |
+
await asyncio.sleep(0)
|
| 225 |
+
translated = cached_translate(pair, text)
|
| 226 |
+
latency = time.time() - start_time
|
| 227 |
+
results.append(translated if translated else "")
|
| 228 |
+
latencies.append(latency)
|
| 229 |
+
logger.info(f"Batch translation {pair}: '{text[:30]}...' -> '{str(translated)[:30]}...' | latency: {latency:.3f}s")
|
| 230 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="success").inc()
|
| 231 |
+
TRANSLATION_LATENCY.labels(pair=str(pair)).observe(latency)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
results.append("")
|
| 234 |
+
latencies.append(None)
|
| 235 |
+
logger.error(f"Batch translation error for {pair}: {e}")
|
| 236 |
+
TRANSLATION_COUNT.labels(pair=str(pair), status="error").inc()
|
| 237 |
+
return {"translated_texts": results, "latencies": latencies}
|
| 238 |
+
|
| 239 |
+
# Add more endpoints as needed (e.g., batch translation, STT, etc.)
|
| 240 |
+
|
| 241 |
+
# To run: uvicorn translation_backend_masterpiece:app --reload
|
src/App.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AuthProvider } from './contexts/AuthContext';
|
| 2 |
+
import { useAuth } from './contexts/useAuth';
|
| 3 |
+
import { NavigationProvider } from './components/Navigation';
|
| 4 |
+
import { useCurrentPath } from './components/NavigationHooks';
|
| 5 |
+
import { Auth } from './components/Auth';
|
| 6 |
+
import { Dashboard } from './components/Dashboard';
|
| 7 |
+
import { HostView } from './components/HostView';
|
| 8 |
+
import { ViewerView } from './components/ViewerView';
|
| 9 |
+
import { Component, ErrorInfo, ReactNode } from 'react';
|
| 10 |
+
|
| 11 |
+
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: any }> {
|
| 12 |
+
constructor(props: any) {
|
| 13 |
+
super(props);
|
| 14 |
+
this.state = { hasError: false, error: null };
|
| 15 |
+
}
|
| 16 |
+
static getDerivedStateFromError(error: any) {
|
| 17 |
+
return { hasError: true, error };
|
| 18 |
+
}
|
| 19 |
+
// componentDidCatch can be used to log errors to a service if needed
|
| 20 |
+
componentDidCatch(_error: any, _info: ErrorInfo) {}
|
| 21 |
+
render() {
|
| 22 |
+
if (this.state.hasError) {
|
| 23 |
+
return (
|
| 24 |
+
<div className="min-h-screen flex items-center justify-center bg-red-50">
|
| 25 |
+
<div className="bg-white rounded-xl shadow-xl p-8 max-w-md mx-auto text-center">
|
| 26 |
+
<h1 className="text-2xl font-bold text-red-700 mb-4">Something went wrong</h1>
|
| 27 |
+
<p className="text-gray-700 mb-2">An unexpected error occurred. Please refresh the page or try again later.</p>
|
| 28 |
+
<pre className="text-xs text-gray-400 mb-2">{String(this.state.error)}</pre>
|
| 29 |
+
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg" onClick={() => window.location.reload()}>Reload</button>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
return this.props.children;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function AppContent() {
|
| 39 |
+
const { user, loading } = useAuth();
|
| 40 |
+
const currentPath = useCurrentPath();
|
| 41 |
+
console.log('[AppContent] user:', user, 'loading:', loading, 'currentPath:', currentPath);
|
| 42 |
+
|
| 43 |
+
if (loading) {
|
| 44 |
+
return (
|
| 45 |
+
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
| 46 |
+
<div className="text-center">
|
| 47 |
+
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mx-auto mb-4"></div>
|
| 48 |
+
<p className="text-gray-600 text-lg">Loading...</p>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (!user) {
|
| 55 |
+
return <Auth />;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (currentPath.startsWith('/host/')) {
|
| 59 |
+
const groupId = currentPath.split('/')[2];
|
| 60 |
+
return <HostView groupId={groupId} />;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (currentPath.startsWith('/viewer/')) {
|
| 64 |
+
const groupId = currentPath.split('/')[2];
|
| 65 |
+
return <ViewerView groupId={groupId} />;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return <Dashboard />;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function App() {
|
| 72 |
+
return (
|
| 73 |
+
<ErrorBoundary>
|
| 74 |
+
<AuthProvider>
|
| 75 |
+
<NavigationProvider>
|
| 76 |
+
<AppContent />
|
| 77 |
+
</NavigationProvider>
|
| 78 |
+
</AuthProvider>
|
| 79 |
+
</ErrorBoundary>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export default App;
|
src/components/Auth.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { useAuth } from '../contexts/useAuth';
|
| 3 |
+
import { Mic, Globe } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
export function Auth() {
|
| 6 |
+
const [isSignUp, setIsSignUp] = useState(false);
|
| 7 |
+
const [email, setEmail] = useState('');
|
| 8 |
+
const [password, setPassword] = useState('');
|
| 9 |
+
const [error, setError] = useState('');
|
| 10 |
+
const [loading, setLoading] = useState(false);
|
| 11 |
+
const { signIn, signUp } = useAuth();
|
| 12 |
+
|
| 13 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 14 |
+
e.preventDefault();
|
| 15 |
+
setError('');
|
| 16 |
+
setLoading(true);
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
if (isSignUp) {
|
| 20 |
+
await signUp(email, password);
|
| 21 |
+
} else {
|
| 22 |
+
await signIn(email, password);
|
| 23 |
+
}
|
| 24 |
+
} catch (err) {
|
| 25 |
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
| 26 |
+
} finally {
|
| 27 |
+
setLoading(false);
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
| 33 |
+
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-8">
|
| 34 |
+
<div className="flex items-center justify-center mb-8">
|
| 35 |
+
<div className="bg-blue-600 rounded-full p-3 mr-3">
|
| 36 |
+
<Mic className="w-8 h-8 text-white" />
|
| 37 |
+
</div>
|
| 38 |
+
<Globe className="w-8 h-8 text-blue-600" />
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<h1 className="text-3xl font-bold text-center text-gray-900 mb-2">
|
| 42 |
+
LiveTranscribe
|
| 43 |
+
</h1>
|
| 44 |
+
<p className="text-center text-gray-600 mb-8">
|
| 45 |
+
Real-time transcription and translation streaming
|
| 46 |
+
</p>
|
| 47 |
+
|
| 48 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 49 |
+
<div>
|
| 50 |
+
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
| 51 |
+
Email
|
| 52 |
+
</label>
|
| 53 |
+
<input
|
| 54 |
+
id="email"
|
| 55 |
+
type="email"
|
| 56 |
+
value={email}
|
| 57 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 58 |
+
required
|
| 59 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 60 |
+
placeholder="you@example.com"
|
| 61 |
+
/>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div>
|
| 65 |
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
| 66 |
+
Password
|
| 67 |
+
</label>
|
| 68 |
+
<input
|
| 69 |
+
id="password"
|
| 70 |
+
type="password"
|
| 71 |
+
value={password}
|
| 72 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 73 |
+
required
|
| 74 |
+
minLength={6}
|
| 75 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 76 |
+
placeholder="••••••••"
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{error && (
|
| 81 |
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
| 82 |
+
{error}
|
| 83 |
+
</div>
|
| 84 |
+
)}
|
| 85 |
+
|
| 86 |
+
<button
|
| 87 |
+
type="submit"
|
| 88 |
+
disabled={loading}
|
| 89 |
+
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 90 |
+
>
|
| 91 |
+
{loading ? 'Please wait...' : isSignUp ? 'Sign Up' : 'Sign In'}
|
| 92 |
+
</button>
|
| 93 |
+
</form>
|
| 94 |
+
|
| 95 |
+
<div className="mt-6 text-center">
|
| 96 |
+
<button
|
| 97 |
+
onClick={() => setIsSignUp(!isSignUp)}
|
| 98 |
+
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
| 99 |
+
>
|
| 100 |
+
{isSignUp ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
| 101 |
+
</button>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
);
|
| 106 |
+
}
|
src/components/CreateGroup.tsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { X, Loader2 } from 'lucide-react';
|
| 3 |
+
import { LocalDB } from '../lib/localdb';
|
| 4 |
+
import { useAuth } from '../contexts/useAuth';
|
| 5 |
+
import { useNavigate } from './NavigationHooks';
|
| 6 |
+
|
| 7 |
+
const SOURCE_LANGUAGES = [
|
| 8 |
+
{ code: 'auto', name: 'Auto (detect)' },
|
| 9 |
+
{ code: 'en', name: 'English' },
|
| 10 |
+
{ code: 'ur', name: 'Urdu' },
|
| 11 |
+
{ code: 'ar', name: 'Arabic' },
|
| 12 |
+
{ code: 'hi', name: 'Hindi' },
|
| 13 |
+
{ code: 'zh', name: 'Chinese' },
|
| 14 |
+
{ code: 'ms', name: 'Malay' },
|
| 15 |
+
];
|
| 16 |
+
|
| 17 |
+
const TARGET_LANGUAGES = [
|
| 18 |
+
{ code: 'en', name: 'English' },
|
| 19 |
+
{ code: 'ur', name: 'Urdu' },
|
| 20 |
+
{ code: 'ar', name: 'Arabic' },
|
| 21 |
+
{ code: 'hi', name: 'Hindi' },
|
| 22 |
+
{ code: 'zh', name: 'Chinese' },
|
| 23 |
+
{ code: 'ms', name: 'Malay' },
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
interface CreateGroupProps {
|
| 27 |
+
onClose: () => void;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function CreateGroup({ onClose }: CreateGroupProps) {
|
| 31 |
+
const { user } = useAuth();
|
| 32 |
+
const navigate = useNavigate();
|
| 33 |
+
const [name, setName] = useState('');
|
| 34 |
+
const [sourceLanguage, setSourceLanguage] = useState('auto');
|
| 35 |
+
const [targetLanguage, setTargetLanguage] = useState('en');
|
| 36 |
+
const [loading, setLoading] = useState(false);
|
| 37 |
+
const [error, setError] = useState('');
|
| 38 |
+
|
| 39 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 40 |
+
e.preventDefault();
|
| 41 |
+
if (!user) return;
|
| 42 |
+
|
| 43 |
+
setLoading(true);
|
| 44 |
+
setError('');
|
| 45 |
+
|
| 46 |
+
try {
|
| 47 |
+
if (sourceLanguage === targetLanguage) {
|
| 48 |
+
setError('Source and target languages must be different.');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
const joinCode = LocalDB.generateUniqueJoinCode();
|
| 52 |
+
const group = LocalDB.createGroup({
|
| 53 |
+
name,
|
| 54 |
+
hostId: user.id,
|
| 55 |
+
joinCode,
|
| 56 |
+
sourceLanguage,
|
| 57 |
+
targetLanguage,
|
| 58 |
+
});
|
| 59 |
+
LocalDB.addMember(group.id, user.id, true);
|
| 60 |
+
|
| 61 |
+
navigate(`/host/${group.id}`);
|
| 62 |
+
onClose();
|
| 63 |
+
} catch (err) {
|
| 64 |
+
setError(err instanceof Error ? err.message : 'Failed to create group');
|
| 65 |
+
} finally {
|
| 66 |
+
setLoading(false);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
| 72 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
| 73 |
+
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
| 74 |
+
<h2 className="text-2xl font-bold text-gray-900">Host a Session</h2>
|
| 75 |
+
<button
|
| 76 |
+
onClick={onClose}
|
| 77 |
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
| 78 |
+
>
|
| 79 |
+
<X className="w-6 h-6" />
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
| 84 |
+
<div>
|
| 85 |
+
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
| 86 |
+
Session Name
|
| 87 |
+
</label>
|
| 88 |
+
<input
|
| 89 |
+
id="name"
|
| 90 |
+
type="text"
|
| 91 |
+
value={name}
|
| 92 |
+
onChange={(e) => setName(e.target.value)}
|
| 93 |
+
required
|
| 94 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 95 |
+
placeholder="Friday Lecture"
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div>
|
| 100 |
+
<label htmlFor="sourceLanguage" className="block text-sm font-medium text-gray-700 mb-1">
|
| 101 |
+
Source Language
|
| 102 |
+
</label>
|
| 103 |
+
<select
|
| 104 |
+
id="sourceLanguage"
|
| 105 |
+
value={sourceLanguage}
|
| 106 |
+
onChange={(e) => setSourceLanguage(e.target.value)}
|
| 107 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 108 |
+
>
|
| 109 |
+
{SOURCE_LANGUAGES.map((lang) => (
|
| 110 |
+
<option key={lang.code} value={lang.code}>
|
| 111 |
+
{lang.name}
|
| 112 |
+
</option>
|
| 113 |
+
))}
|
| 114 |
+
</select>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div>
|
| 118 |
+
<label htmlFor="targetLanguage" className="block text-sm font-medium text-gray-700 mb-1">
|
| 119 |
+
Target Language
|
| 120 |
+
</label>
|
| 121 |
+
<select
|
| 122 |
+
id="targetLanguage"
|
| 123 |
+
value={targetLanguage}
|
| 124 |
+
onChange={(e) => setTargetLanguage(e.target.value)}
|
| 125 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 126 |
+
>
|
| 127 |
+
{TARGET_LANGUAGES.map((lang) => (
|
| 128 |
+
<option key={lang.code} value={lang.code}>
|
| 129 |
+
{lang.name}
|
| 130 |
+
</option>
|
| 131 |
+
))}
|
| 132 |
+
</select>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{error && (
|
| 136 |
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
| 137 |
+
{error}
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
|
| 141 |
+
<button
|
| 142 |
+
type="submit"
|
| 143 |
+
disabled={loading || sourceLanguage === targetLanguage}
|
| 144 |
+
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
| 145 |
+
>
|
| 146 |
+
{loading ? (
|
| 147 |
+
<>
|
| 148 |
+
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
| 149 |
+
Creating...
|
| 150 |
+
</>
|
| 151 |
+
) : (
|
| 152 |
+
'Create Session'
|
| 153 |
+
)}
|
| 154 |
+
</button>
|
| 155 |
+
</form>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
);
|
| 159 |
+
}
|
src/components/Dashboard.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { useAuth } from '../contexts/useAuth';
|
| 3 |
+
import { Plus, LogOut, Radio, Users } from 'lucide-react';
|
| 4 |
+
import { CreateGroup } from './CreateGroup';
|
| 5 |
+
import { JoinGroup } from './JoinGroup';
|
| 6 |
+
import { MyGroups } from './MyGroups';
|
| 7 |
+
|
| 8 |
+
export function Dashboard() {
|
| 9 |
+
const { signOut } = useAuth();
|
| 10 |
+
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
| 11 |
+
const [showJoinGroup, setShowJoinGroup] = useState(false);
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
| 15 |
+
<nav className="bg-white shadow-sm border-b border-gray-200">
|
| 16 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 17 |
+
<div className="flex justify-between items-center h-16">
|
| 18 |
+
<div className="flex items-center">
|
| 19 |
+
<Radio className="w-8 h-8 text-blue-600 mr-2" />
|
| 20 |
+
<h1 className="text-2xl font-bold text-gray-900">LiveTranscribe</h1>
|
| 21 |
+
</div>
|
| 22 |
+
<button
|
| 23 |
+
onClick={signOut}
|
| 24 |
+
className="flex items-center text-gray-700 hover:text-gray-900 transition-colors"
|
| 25 |
+
>
|
| 26 |
+
<LogOut className="w-5 h-5 mr-2" />
|
| 27 |
+
Sign Out
|
| 28 |
+
</button>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</nav>
|
| 32 |
+
|
| 33 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 34 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
| 35 |
+
<button
|
| 36 |
+
onClick={() => setShowCreateGroup(true)}
|
| 37 |
+
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-8 text-left group"
|
| 38 |
+
>
|
| 39 |
+
<div className="bg-blue-100 rounded-full w-16 h-16 flex items-center justify-center mb-4 group-hover:bg-blue-200 transition-colors">
|
| 40 |
+
<Plus className="w-8 h-8 text-blue-600" />
|
| 41 |
+
</div>
|
| 42 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-2">Host a Session</h2>
|
| 43 |
+
<p className="text-gray-600">
|
| 44 |
+
Create a new transcription session and broadcast live text to multiple devices
|
| 45 |
+
</p>
|
| 46 |
+
</button>
|
| 47 |
+
|
| 48 |
+
<button
|
| 49 |
+
onClick={() => setShowJoinGroup(true)}
|
| 50 |
+
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-8 text-left group"
|
| 51 |
+
>
|
| 52 |
+
<div className="bg-green-100 rounded-full w-16 h-16 flex items-center justify-center mb-4 group-hover:bg-green-200 transition-colors">
|
| 53 |
+
<Users className="w-8 h-8 text-green-600" />
|
| 54 |
+
</div>
|
| 55 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-2">Join a Session</h2>
|
| 56 |
+
<p className="text-gray-600">
|
| 57 |
+
Enter a code or scan QR to receive live transcriptions on your device
|
| 58 |
+
</p>
|
| 59 |
+
</button>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<MyGroups />
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{showCreateGroup && (
|
| 66 |
+
<CreateGroup onClose={() => setShowCreateGroup(false)} />
|
| 67 |
+
)}
|
| 68 |
+
|
| 69 |
+
{showJoinGroup && (
|
| 70 |
+
<JoinGroup onClose={() => setShowJoinGroup(false)} />
|
| 71 |
+
)}
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
src/components/HostView.tsx
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState, useRef } from 'react';
|
| 2 |
+
import { LocalDB, Group, TranscriptionSegment } from '../lib/localdb';
|
| 3 |
+
import { translateText, apiHealth, preloadModel, sttStatus, transcribeAudio } from '../lib/api';
|
| 4 |
+
import { useAuth } from '../contexts/useAuth';
|
| 5 |
+
import { useNavigate } from './NavigationHooks';
|
| 6 |
+
import { Mic, MicOff, StopCircle, Copy, Check, QrCode, Users, Trash2, Download } from 'lucide-react';
|
| 7 |
+
import { QRCodeDisplay } from './QRCodeDisplay';
|
| 8 |
+
|
| 9 |
+
interface HostViewProps {
|
| 10 |
+
groupId: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function HostView({ groupId }: HostViewProps) {
|
| 14 |
+
// ...existing code...
|
| 15 |
+
// Subscribe to group updates so HostView always reflects latest source/target language
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
const unsubGroup = LocalDB.onGroupUpdated(groupId, (updated) => {
|
| 18 |
+
setGroup(updated);
|
| 19 |
+
});
|
| 20 |
+
return () => unsubGroup();
|
| 21 |
+
}, [groupId]);
|
| 22 |
+
// ...existing code...
|
| 23 |
+
// Show only originals toggle
|
| 24 |
+
const [showOriginalsOnly, setShowOriginalsOnly] = useState(false);
|
| 25 |
+
// Malay-English model test state
|
| 26 |
+
const [msEnAvailable, setMsEnAvailable] = useState<null | boolean>(null);
|
| 27 |
+
const [msEnChecking, setMsEnChecking] = useState(false);
|
| 28 |
+
// Parallel translation: track IDs currently being translated
|
| 29 |
+
const [translatingIds, setTranslatingIds] = useState<Set<string>>(new Set());
|
| 30 |
+
const { user } = useAuth();
|
| 31 |
+
const navigate = useNavigate();
|
| 32 |
+
const [group, setGroup] = useState<Group | null>(null);
|
| 33 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 34 |
+
// Segments always sorted by sequence number
|
| 35 |
+
const [segments, setSegments] = useState<TranscriptionSegment[]>([]);
|
| 36 |
+
const [memberCount, setMemberCount] = useState(0);
|
| 37 |
+
const [showQR, setShowQR] = useState(false);
|
| 38 |
+
const [copied, setCopied] = useState(false);
|
| 39 |
+
const [interimText, setInterimText] = useState('');
|
| 40 |
+
const [apiOk, setApiOk] = useState<boolean | null>(null);
|
| 41 |
+
// Advanced audio upload support
|
| 42 |
+
const [sttAvailable, setSttAvailable] = useState(false);
|
| 43 |
+
const [sttBusy, setSttBusy] = useState(false);
|
| 44 |
+
// Removed unused diag variable
|
| 45 |
+
const [retryingIds, setRetryingIds] = useState<Set<string>>(new Set());
|
| 46 |
+
const [bulkRetrying, setBulkRetrying] = useState(false);
|
| 47 |
+
// Sentence-level buffering for mic recognition
|
| 48 |
+
const [bufferedText, setBufferedText] = useState('');
|
| 49 |
+
const flushTimerRef = useRef<any>(null);
|
| 50 |
+
|
| 51 |
+
const recognitionRef = useRef<any>(null);
|
| 52 |
+
const isRecordingFlagRef = useRef(false);
|
| 53 |
+
const sequenceNumberRef = useRef(0);
|
| 54 |
+
const preloadAttemptedRef = useRef(false);
|
| 55 |
+
const [modelReady, setModelReady] = useState<null | boolean>(null);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
if (!user) return;
|
| 59 |
+
|
| 60 |
+
loadGroup();
|
| 61 |
+
loadSegments();
|
| 62 |
+
loadMemberCount();
|
| 63 |
+
// Preload model for selected target language when group changes
|
| 64 |
+
if (group && apiOk) {
|
| 65 |
+
preloadModel({ source_language: group.source_language, target_language: group.target_language });
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const unsubMembers = LocalDB.onMembersChanged(groupId, () => {
|
| 69 |
+
loadMemberCount();
|
| 70 |
+
});
|
| 71 |
+
// poll API health
|
| 72 |
+
let healthTimer: any;
|
| 73 |
+
(async () => {
|
| 74 |
+
const ok = await apiHealth();
|
| 75 |
+
setApiOk(ok);
|
| 76 |
+
// Check if Whisper STT is available (backend should expose this)
|
| 77 |
+
if (ok) {
|
| 78 |
+
try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); }
|
| 79 |
+
}
|
| 80 |
+
// Auto-preload model when API is online and group is known
|
| 81 |
+
if (ok && group && !preloadAttemptedRef.current) {
|
| 82 |
+
preloadAttemptedRef.current = true;
|
| 83 |
+
setModelReady(null);
|
| 84 |
+
const warmed = await autoPreloadForGroup(group);
|
| 85 |
+
setModelReady(warmed);
|
| 86 |
+
}
|
| 87 |
+
healthTimer = setInterval(async () => {
|
| 88 |
+
const healthy = await apiHealth();
|
| 89 |
+
setApiOk(healthy);
|
| 90 |
+
if (healthy) {
|
| 91 |
+
try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); }
|
| 92 |
+
} else {
|
| 93 |
+
setSttAvailable(false);
|
| 94 |
+
}
|
| 95 |
+
// Re-attempt preload if API just came online
|
| 96 |
+
if (healthy && group && !preloadAttemptedRef.current) {
|
| 97 |
+
preloadAttemptedRef.current = true;
|
| 98 |
+
setModelReady(null);
|
| 99 |
+
const warmed = await autoPreloadForGroup(group);
|
| 100 |
+
setModelReady(warmed);
|
| 101 |
+
}
|
| 102 |
+
}, 15000);
|
| 103 |
+
})();
|
| 104 |
+
|
| 105 |
+
return () => {
|
| 106 |
+
unsubMembers();
|
| 107 |
+
stopRecording();
|
| 108 |
+
if (healthTimer) clearInterval(healthTimer);
|
| 109 |
+
};
|
| 110 |
+
}, [groupId, user]);
|
| 111 |
+
|
| 112 |
+
// When group becomes available, kick off auto preload once
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
if (group) {
|
| 115 |
+
if (apiOk && !preloadAttemptedRef.current) {
|
| 116 |
+
preloadAttemptedRef.current = true;
|
| 117 |
+
(async () => {
|
| 118 |
+
setModelReady(null);
|
| 119 |
+
const warmed = await autoPreloadForGroup(group);
|
| 120 |
+
setModelReady(warmed);
|
| 121 |
+
})();
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
}, [group?.id, apiOk]);
|
| 125 |
+
|
| 126 |
+
const autoPreloadForGroup = async (g: Group): Promise<boolean> => {
|
| 127 |
+
let ok = await preloadModel({ source_language: g.source_language, target_language: g.target_language });
|
| 128 |
+
if (!ok && g.target_language === 'en' && (g.source_language === 'auto' || !g.source_language)) {
|
| 129 |
+
const okMs = await preloadModel({ source_language: 'ms', target_language: 'en' });
|
| 130 |
+
const okId = await preloadModel({ source_language: 'id', target_language: 'en' });
|
| 131 |
+
ok = okMs || okId;
|
| 132 |
+
}
|
| 133 |
+
return ok;
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const loadGroup = async () => {
|
| 137 |
+
const data = LocalDB.getGroupById(groupId);
|
| 138 |
+
if (data) {
|
| 139 |
+
setGroup(data);
|
| 140 |
+
} else {
|
| 141 |
+
navigate('/');
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const loadSegments = async () => {
|
| 146 |
+
const data = LocalDB.getSegments(groupId);
|
| 147 |
+
setSegments(data);
|
| 148 |
+
sequenceNumberRef.current = data.length;
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const loadMemberCount = async () => {
|
| 152 |
+
setMemberCount(LocalDB.getMemberCount(groupId));
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const setRecording = (v: boolean) => {
|
| 156 |
+
setIsRecording(v);
|
| 157 |
+
isRecordingFlagRef.current = v;
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const startRecording = async () => {
|
| 161 |
+
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
| 162 |
+
alert('Speech recognition is not supported in your browser. Please use Chrome or Edge.');
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
| 167 |
+
const recognition = new SpeechRecognition();
|
| 168 |
+
|
| 169 |
+
recognition.continuous = true;
|
| 170 |
+
recognition.interimResults = true;
|
| 171 |
+
const srcLang = (group?.source_language === 'auto' ? 'en' : group?.source_language || 'en').toLowerCase();
|
| 172 |
+
recognition.lang = srcLang === 'auto' ? 'en-US'
|
| 173 |
+
: srcLang === 'ur' ? 'ur-PK'
|
| 174 |
+
: srcLang === 'ar' ? 'ar-SA'
|
| 175 |
+
: srcLang === 'es' ? 'es-ES'
|
| 176 |
+
: srcLang === 'fr' ? 'fr-FR'
|
| 177 |
+
: srcLang === 'de' ? 'de-DE'
|
| 178 |
+
: srcLang === 'hi' ? 'hi-IN'
|
| 179 |
+
: srcLang === 'zh' ? 'zh-CN'
|
| 180 |
+
: srcLang === 'ms' ? 'ms-MY'
|
| 181 |
+
: srcLang === 'id' ? 'id-ID'
|
| 182 |
+
: 'en-US';
|
| 183 |
+
|
| 184 |
+
recognition.onresult = async (event: any) => {
|
| 185 |
+
let interim = '';
|
| 186 |
+
let finalChunk = '';
|
| 187 |
+
|
| 188 |
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
| 189 |
+
const transcript = (event.results[i][0].transcript || '').trim();
|
| 190 |
+
if (!transcript) continue;
|
| 191 |
+
if (event.results[i].isFinal) {
|
| 192 |
+
finalChunk += (finalChunk ? ' ' : '') + transcript;
|
| 193 |
+
} else {
|
| 194 |
+
interim += (interim ? ' ' : '') + transcript;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (interim) {
|
| 199 |
+
setInterimText(interim);
|
| 200 |
+
} else {
|
| 201 |
+
setInterimText('');
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Append final chunk to buffer and flush on sentence boundaries or heuristics
|
| 205 |
+
if (finalChunk) {
|
| 206 |
+
const combined = (bufferedText + ' ' + finalChunk).replace(/\s+/g, ' ').trim();
|
| 207 |
+
setBufferedText(combined);
|
| 208 |
+
|
| 209 |
+
// Heuristics for when to flush the buffered sentence
|
| 210 |
+
const endsSentence = /[.!?…]\s*$/.test(combined);
|
| 211 |
+
const wordCount = combined.split(/\s+/).filter(Boolean).length;
|
| 212 |
+
const overChars = combined.length >= 180; // max chars per segment
|
| 213 |
+
const minWordsReached = wordCount >= 10; // avoid word-by-word for long speech
|
| 214 |
+
|
| 215 |
+
// Clear any previous pending flush; we will schedule a new one below
|
| 216 |
+
if (flushTimerRef.current) {
|
| 217 |
+
clearTimeout(flushTimerRef.current);
|
| 218 |
+
flushTimerRef.current = null;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const doFlush = async (forced = false) => {
|
| 222 |
+
const txt = combined.trim();
|
| 223 |
+
if (!txt) return;
|
| 224 |
+
// Skip ultra-short fillers unless sentence ended
|
| 225 |
+
const wc = txt.split(/\s+/).filter(Boolean).length;
|
| 226 |
+
// Immediate flush (on punctuation/length) keeps stricter rule; timer-based can be lenient
|
| 227 |
+
if (!forced) {
|
| 228 |
+
if (!endsSentence && wc < 3) return; // allow short 2-3 word utterances only if they feel like a sentence
|
| 229 |
+
} else {
|
| 230 |
+
if (wc < 1) return; // if timer fired and there's at least one word, flush
|
| 231 |
+
}
|
| 232 |
+
setBufferedText('');
|
| 233 |
+
await saveSegment(txt);
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
if (endsSentence || overChars || (minWordsReached && !interim)) {
|
| 237 |
+
await doFlush(false);
|
| 238 |
+
} else {
|
| 239 |
+
// Debounce-based flush after short pause
|
| 240 |
+
flushTimerRef.current = setTimeout(async () => {
|
| 241 |
+
await doFlush(true);
|
| 242 |
+
}, 1500);
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
recognition.onerror = (event: any) => {
|
| 248 |
+
console.error('Speech recognition error:', event.error);
|
| 249 |
+
if (event.error === 'no-speech') {
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
setRecording(false);
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
recognition.onend = () => {
|
| 256 |
+
if (isRecordingFlagRef.current) {
|
| 257 |
+
try {
|
| 258 |
+
recognition.start();
|
| 259 |
+
} catch (e) {
|
| 260 |
+
setTimeout(() => {
|
| 261 |
+
if (isRecordingFlagRef.current) {
|
| 262 |
+
try { recognition.start(); } catch (_e) { /* ignore restart error */ }
|
| 263 |
+
}
|
| 264 |
+
}, 250);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
setRecording(true);
|
| 270 |
+
recognition.start();
|
| 271 |
+
recognitionRef.current = recognition;
|
| 272 |
+
};
|
| 273 |
+
|
| 274 |
+
const stopRecording = () => {
|
| 275 |
+
if (recognitionRef.current) {
|
| 276 |
+
setRecording(false);
|
| 277 |
+
recognitionRef.current.stop();
|
| 278 |
+
recognitionRef.current = null;
|
| 279 |
+
}
|
| 280 |
+
setInterimText('');
|
| 281 |
+
setBufferedText('');
|
| 282 |
+
if (flushTimerRef.current) {
|
| 283 |
+
clearTimeout(flushTimerRef.current);
|
| 284 |
+
flushTimerRef.current = null;
|
| 285 |
+
}
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
// Save a segment and always add in order
|
| 289 |
+
// Save segment and add to translation queue
|
| 290 |
+
const saveSegment = async (text: string) => {
|
| 291 |
+
if (!user || !group) return;
|
| 292 |
+
// Add segment immediately with translation: null (processing)
|
| 293 |
+
const tempSegment = LocalDB.addSegment({
|
| 294 |
+
groupId,
|
| 295 |
+
originalText: text,
|
| 296 |
+
translatedText: null,
|
| 297 |
+
sequenceNumber: sequenceNumberRef.current,
|
| 298 |
+
createdBy: user.id,
|
| 299 |
+
});
|
| 300 |
+
setSegments((prev: TranscriptionSegment[]) => {
|
| 301 |
+
const exists = prev.some((s: TranscriptionSegment) => s.id === tempSegment.id);
|
| 302 |
+
if (exists) return prev;
|
| 303 |
+
return [...prev, tempSegment].sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number);
|
| 304 |
+
});
|
| 305 |
+
sequenceNumberRef.current++;
|
| 306 |
+
// Start translation in background for this segment
|
| 307 |
+
setTranslatingIds((prev) => new Set([...prev, tempSegment.id]));
|
| 308 |
+
translateSegment(tempSegment);
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
// Translate a segment in the background
|
| 312 |
+
const translateSegment = async (segment: TranscriptionSegment) => {
|
| 313 |
+
if (!group) return;
|
| 314 |
+
let translatedText: string | null = null;
|
| 315 |
+
if (group && group.source_language !== group.target_language) {
|
| 316 |
+
const attempts: string[] = [];
|
| 317 |
+
attempts.push(group.source_language === 'auto' ? 'auto' : group.source_language);
|
| 318 |
+
if (!attempts.includes('ms')) attempts.push('ms');
|
| 319 |
+
if (!attempts.includes('id')) attempts.push('id');
|
| 320 |
+
let foundTranslation: string | null = null;
|
| 321 |
+
for (const src of attempts) {
|
| 322 |
+
const res = await translateText({ text: segment.original_text, source_language: src, target_language: group.target_language });
|
| 323 |
+
const isNoModel = typeof res === 'string' && /^\[No model for/i.test(res);
|
| 324 |
+
const isEcho = !!res && res.trim() === segment.original_text.trim();
|
| 325 |
+
if (res && !isNoModel && !isEcho) {
|
| 326 |
+
foundTranslation = res;
|
| 327 |
+
break;
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
if (foundTranslation) {
|
| 331 |
+
translatedText = foundTranslation;
|
| 332 |
+
}
|
| 333 |
+
} else {
|
| 334 |
+
translatedText = segment.original_text;
|
| 335 |
+
}
|
| 336 |
+
setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
|
| 337 |
+
s.id === segment.id ? { ...s, translated_text: translatedText } : s
|
| 338 |
+
).sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number));
|
| 339 |
+
LocalDB.updateSegmentTranslation(groupId, segment.id, translatedText);
|
| 340 |
+
setTranslatingIds((prev) => {
|
| 341 |
+
const next = new Set(prev);
|
| 342 |
+
next.delete(segment.id);
|
| 343 |
+
return next;
|
| 344 |
+
});
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// Retry translation for a segment
|
| 348 |
+
const retryTranslateOne = async (seg: TranscriptionSegment) => {
|
| 349 |
+
if (!group) return;
|
| 350 |
+
setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id)));
|
| 351 |
+
setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
|
| 352 |
+
s.id === seg.id ? { ...s, translated_text: null } : s
|
| 353 |
+
));
|
| 354 |
+
setTranslatingIds((prev) => new Set([...prev, seg.id]));
|
| 355 |
+
await translateSegment(seg);
|
| 356 |
+
setRetryingIds((prev) => {
|
| 357 |
+
const next = new Set(prev);
|
| 358 |
+
next.delete(seg.id);
|
| 359 |
+
return next;
|
| 360 |
+
});
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
// Translate all segments currently missing a translation
|
| 364 |
+
const retryTranslateMissing = async () => {
|
| 365 |
+
if (!group) return;
|
| 366 |
+
setBulkRetrying(true);
|
| 367 |
+
try {
|
| 368 |
+
const missing = segments.filter((seg) => !seg.translated_text || seg.translated_text === seg.original_text);
|
| 369 |
+
await Promise.all(missing.map(async (seg) => {
|
| 370 |
+
setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id)));
|
| 371 |
+
setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) =>
|
| 372 |
+
s.id === seg.id ? { ...s, translated_text: null } : s
|
| 373 |
+
));
|
| 374 |
+
setTranslatingIds((prev) => new Set([...prev, seg.id]));
|
| 375 |
+
await translateSegment(seg);
|
| 376 |
+
setRetryingIds((prev) => {
|
| 377 |
+
const next = new Set(prev);
|
| 378 |
+
next.delete(seg.id);
|
| 379 |
+
return next;
|
| 380 |
+
});
|
| 381 |
+
}));
|
| 382 |
+
} finally {
|
| 383 |
+
setBulkRetrying(false);
|
| 384 |
+
}
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
const endSession = async () => {
|
| 388 |
+
stopRecording();
|
| 389 |
+
LocalDB.setGroupActive(groupId, false);
|
| 390 |
+
|
| 391 |
+
navigate('/');
|
| 392 |
+
};
|
| 393 |
+
|
| 394 |
+
// Upload removed
|
| 395 |
+
|
| 396 |
+
const deleteSession = async () => {
|
| 397 |
+
stopRecording();
|
| 398 |
+
if (confirm('Delete this session? This will remove all local data for it.')) {
|
| 399 |
+
LocalDB.deleteGroup(groupId);
|
| 400 |
+
navigate('/');
|
| 401 |
+
}
|
| 402 |
+
};
|
| 403 |
+
|
| 404 |
+
const copyJoinCode = () => {
|
| 405 |
+
if (group) {
|
| 406 |
+
navigator.clipboard.writeText(group.join_code);
|
| 407 |
+
setCopied(true);
|
| 408 |
+
setTimeout(() => setCopied(false), 2000);
|
| 409 |
+
}
|
| 410 |
+
};
|
| 411 |
+
|
| 412 |
+
// Manual preload removed; handled automatically via autoPreloadForGroup()
|
| 413 |
+
|
| 414 |
+
const handleExport = async () => {
|
| 415 |
+
if (!group) return;
|
| 416 |
+
const data = LocalDB.exportGroup(groupId);
|
| 417 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
| 418 |
+
const url = URL.createObjectURL(blob);
|
| 419 |
+
const a = document.createElement('a');
|
| 420 |
+
a.href = url;
|
| 421 |
+
a.download = `${group.name.replace(/[^a-z0-9-]+/gi, '_') || 'session'}_${group.join_code}.json`;
|
| 422 |
+
document.body.appendChild(a);
|
| 423 |
+
a.click();
|
| 424 |
+
a.remove();
|
| 425 |
+
URL.revokeObjectURL(url);
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
if (!group) {
|
| 429 |
+
return (
|
| 430 |
+
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
| 431 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
| 432 |
+
</div>
|
| 433 |
+
);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
const speechSupported = typeof window !== 'undefined' &&
|
| 437 |
+
(("webkitSpeechRecognition" in window) || ("SpeechRecognition" in window));
|
| 438 |
+
|
| 439 |
+
return (
|
| 440 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
| 441 |
+
<div className="max-w-4xl mx-auto p-4">
|
| 442 |
+
<div className="bg-white rounded-xl shadow-lg mb-4">
|
| 443 |
+
<div className="p-6 border-b border-gray-200">
|
| 444 |
+
<div className="flex items-center justify-between mb-4">
|
| 445 |
+
<h1 className="text-2xl font-bold text-gray-900">{group.name}</h1>
|
| 446 |
+
<div className="flex items-center space-x-2">
|
| 447 |
+
<div className="flex items-center bg-gray-100 rounded-lg px-3 py-2">
|
| 448 |
+
<Users className="w-5 h-5 text-gray-600 mr-2" />
|
| 449 |
+
<span className="font-semibold text-gray-900">{memberCount}</span>
|
| 450 |
+
</div>
|
| 451 |
+
<div className="flex items-center rounded-lg px-3 py-2 border" title={apiOk === null ? 'Checking API' : apiOk ? 'API Online' : 'API Offline'}>
|
| 452 |
+
<span className={`w-2 h-2 rounded-full mr-2 ${apiOk === null ? 'bg-gray-400 animate-pulse' : apiOk ? 'bg-green-600' : 'bg-red-600'}`}></span>
|
| 453 |
+
<span className="text-sm text-gray-700">API</span>
|
| 454 |
+
</div>
|
| 455 |
+
{/* Toggle to show only original text */}
|
| 456 |
+
<label className="ml-2 flex items-center text-xs cursor-pointer">
|
| 457 |
+
<input
|
| 458 |
+
type="checkbox"
|
| 459 |
+
checked={showOriginalsOnly}
|
| 460 |
+
onChange={e => setShowOriginalsOnly(e.target.checked)}
|
| 461 |
+
className="mr-1"
|
| 462 |
+
/>
|
| 463 |
+
Show only originals
|
| 464 |
+
</label>
|
| 465 |
+
{/* Malay-English model test button */}
|
| 466 |
+
<button
|
| 467 |
+
onClick={async () => {
|
| 468 |
+
setMsEnChecking(true);
|
| 469 |
+
setMsEnAvailable(null);
|
| 470 |
+
try {
|
| 471 |
+
const ok = await preloadModel({ source_language: 'ms', target_language: 'en' });
|
| 472 |
+
setMsEnAvailable(ok);
|
| 473 |
+
} finally {
|
| 474 |
+
setMsEnChecking(false);
|
| 475 |
+
}
|
| 476 |
+
}}
|
| 477 |
+
className="ml-2 px-3 py-2 rounded-lg border text-xs"
|
| 478 |
+
disabled={msEnChecking}
|
| 479 |
+
title="Check if Malay-English model is available"
|
| 480 |
+
>
|
| 481 |
+
{msEnChecking ? 'Checking…' : 'Test Malay→English'}
|
| 482 |
+
</button>
|
| 483 |
+
{msEnAvailable !== null && (
|
| 484 |
+
<span className={`ml-2 text-xs font-bold ${msEnAvailable ? 'text-green-600' : 'text-red-600'}`}>{msEnAvailable ? 'Available' : 'Not available'}</span>
|
| 485 |
+
)}
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
|
| 489 |
+
<div className="flex items-center space-x-3">
|
| 490 |
+
<div className="flex-1 bg-blue-50 rounded-lg p-3 flex items-center justify-between">
|
| 491 |
+
<div>
|
| 492 |
+
<span className="text-sm text-gray-600">Join Code:</span>
|
| 493 |
+
<span className="ml-2 text-2xl font-mono font-bold text-blue-600">
|
| 494 |
+
{group.join_code}
|
| 495 |
+
</span>
|
| 496 |
+
</div>
|
| 497 |
+
<button
|
| 498 |
+
onClick={copyJoinCode}
|
| 499 |
+
className="p-2 hover:bg-blue-100 rounded-lg transition-colors"
|
| 500 |
+
>
|
| 501 |
+
{copied ? (
|
| 502 |
+
<Check className="w-5 h-5 text-green-600" />
|
| 503 |
+
) : (
|
| 504 |
+
<Copy className="w-5 h-5 text-blue-600" />
|
| 505 |
+
)}
|
| 506 |
+
</button>
|
| 507 |
+
</div>
|
| 508 |
+
|
| 509 |
+
<button
|
| 510 |
+
onClick={() => setShowQR(true)}
|
| 511 |
+
className="bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition-colors"
|
| 512 |
+
>
|
| 513 |
+
<QrCode className="w-6 h-6" />
|
| 514 |
+
</button>
|
| 515 |
+
</div>
|
| 516 |
+
</div>
|
| 517 |
+
|
| 518 |
+
<div className="p-6">
|
| 519 |
+
<div className="flex items-center justify-center space-x-4 mb-6">
|
| 520 |
+
{!isRecording ? (
|
| 521 |
+
<button
|
| 522 |
+
onClick={startRecording}
|
| 523 |
+
disabled={!speechSupported}
|
| 524 |
+
className="bg-green-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-8 py-4 rounded-full font-semibold hover:bg-green-700 transition-colors flex items-center shadow-lg"
|
| 525 |
+
>
|
| 526 |
+
<Mic className="w-6 h-6 mr-2" />
|
| 527 |
+
Start Recording
|
| 528 |
+
</button>
|
| 529 |
+
) : (
|
| 530 |
+
<button
|
| 531 |
+
onClick={stopRecording}
|
| 532 |
+
className="bg-red-600 text-white px-8 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center shadow-lg animate-pulse"
|
| 533 |
+
>
|
| 534 |
+
<MicOff className="w-6 h-6 mr-2" />
|
| 535 |
+
Stop Recording
|
| 536 |
+
</button>
|
| 537 |
+
)}
|
| 538 |
+
|
| 539 |
+
{/* Mic language selector removed for simplicity; we infer from group source */}
|
| 540 |
+
|
| 541 |
+
<button
|
| 542 |
+
onClick={endSession}
|
| 543 |
+
className="bg-gray-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-gray-700 transition-colors flex items-center"
|
| 544 |
+
>
|
| 545 |
+
<StopCircle className="w-5 h-5 mr-2" />
|
| 546 |
+
End Session
|
| 547 |
+
</button>
|
| 548 |
+
{/* Preload button removed; model warm-up runs automatically */}
|
| 549 |
+
<button
|
| 550 |
+
onClick={deleteSession}
|
| 551 |
+
className="bg-red-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center"
|
| 552 |
+
>
|
| 553 |
+
<Trash2 className="w-5 h-5 mr-2" />
|
| 554 |
+
Delete Session
|
| 555 |
+
</button>
|
| 556 |
+
<button
|
| 557 |
+
onClick={handleExport}
|
| 558 |
+
className="bg-white border px-6 py-4 rounded-full font-semibold hover:bg-gray-50 transition-colors flex items-center"
|
| 559 |
+
title="Export this session as JSON so you can delete safely if storage is full"
|
| 560 |
+
>
|
| 561 |
+
<Download className="w-5 h-5 mr-2" />
|
| 562 |
+
Export JSON
|
| 563 |
+
</button>
|
| 564 |
+
</div>
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
{/* Advanced audio upload for Whisper STT */}
|
| 568 |
+
{apiOk && sttAvailable && (
|
| 569 |
+
<div className="mt-4">
|
| 570 |
+
<label className="block text-sm text-gray-600 mb-1">Upload audio (Whisper)</label>
|
| 571 |
+
<div className="flex items-center gap-2">
|
| 572 |
+
<input
|
| 573 |
+
type="file"
|
| 574 |
+
accept="audio/*"
|
| 575 |
+
onChange={async (e) => {
|
| 576 |
+
const file = e.target.files?.[0];
|
| 577 |
+
if (!file) return;
|
| 578 |
+
setSttBusy(true);
|
| 579 |
+
try {
|
| 580 |
+
// Use transcribeAudio from lib/api for Whisper backend
|
| 581 |
+
const res = await transcribeAudio(file, (group?.source_language || undefined));
|
| 582 |
+
if (res && res.text && res.text.trim()) {
|
| 583 |
+
await saveSegment(res.text.trim());
|
| 584 |
+
}
|
| 585 |
+
} finally {
|
| 586 |
+
setSttBusy(false);
|
| 587 |
+
e.currentTarget.value = '';
|
| 588 |
+
}
|
| 589 |
+
}}
|
| 590 |
+
disabled={sttBusy}
|
| 591 |
+
className="border rounded-lg px-3 py-2 text-sm"
|
| 592 |
+
/>
|
| 593 |
+
{sttBusy && <span className="text-xs text-gray-500">Transcribing…</span>}
|
| 594 |
+
</div>
|
| 595 |
+
<div className="text-xs text-gray-500 mt-1">Supported: common audio types; processed server-side via Whisper.</div>
|
| 596 |
+
</div>
|
| 597 |
+
)}
|
| 598 |
+
{apiOk && (
|
| 599 |
+
<div className="mt-2 text-center">
|
| 600 |
+
<span className="text-xs text-gray-500">
|
| 601 |
+
Model: {modelReady === null ? 'warming…' : modelReady ? 'ready' : 'will load on first request'}
|
| 602 |
+
</span>
|
| 603 |
+
</div>
|
| 604 |
+
)}
|
| 605 |
+
|
| 606 |
+
{isRecording && (
|
| 607 |
+
<div className="text-center mb-4">
|
| 608 |
+
<div className="inline-flex items-center bg-red-100 text-red-700 px-4 py-2 rounded-full">
|
| 609 |
+
<span className="w-3 h-3 bg-red-600 rounded-full mr-2 animate-pulse"></span>
|
| 610 |
+
Recording in progress
|
| 611 |
+
</div>
|
| 612 |
+
</div>
|
| 613 |
+
)}
|
| 614 |
+
{!isRecording && !speechSupported && (
|
| 615 |
+
<div className="text-center mb-4">
|
| 616 |
+
<div className="inline-flex items-center bg-yellow-100 text-yellow-800 px-4 py-2 rounded-full">
|
| 617 |
+
Your browser doesn't support Speech Recognition. Use Chrome/Edge or the manual input below.
|
| 618 |
+
</div>
|
| 619 |
+
</div>
|
| 620 |
+
)}
|
| 621 |
+
{/* Manual input fallback for quick testing or browsers without speech recognition */}
|
| 622 |
+
<ManualInput onSubmit={saveSegment} disabled={!!isRecording && !!recognitionRef.current} />
|
| 623 |
+
</div>
|
| 624 |
+
</div>
|
| 625 |
+
|
| 626 |
+
<div className="bg-white rounded-xl shadow-lg p-6">
|
| 627 |
+
<div className="flex items-center justify-between mb-4">
|
| 628 |
+
<h2 className="text-xl font-bold text-gray-900">Live Transcription</h2>
|
| 629 |
+
<button
|
| 630 |
+
onClick={retryTranslateMissing}
|
| 631 |
+
disabled={bulkRetrying || segments.every((s) => s.translated_text && s.translated_text !== s.original_text)}
|
| 632 |
+
className="text-sm px-3 py-1 rounded-md border disabled:opacity-50 hover:bg-gray-50"
|
| 633 |
+
title="Retry translation for lines without English"
|
| 634 |
+
>
|
| 635 |
+
{bulkRetrying ? 'Translating…' : 'Translate missing'}
|
| 636 |
+
</button>
|
| 637 |
+
</div>
|
| 638 |
+
<div className="space-y-3 max-h-96 overflow-y-auto">
|
| 639 |
+
<div className="text-xs text-gray-500 mb-2">Approx. storage used by this app: {Math.round(LocalDB.getStorageBytes()/1024)} KB</div>
|
| 640 |
+
{segments
|
| 641 |
+
.slice()
|
| 642 |
+
.sort((a, b) => a.sequence_number - b.sequence_number)
|
| 643 |
+
.map((segment) => (
|
| 644 |
+
<div key={segment.id} className="bg-gray-50 rounded-lg p-4">
|
| 645 |
+
<p className="text-gray-900">{segment.original_text}</p>
|
| 646 |
+
{!showOriginalsOnly && (
|
| 647 |
+
<>
|
| 648 |
+
{translatingIds.has(segment.id) || segment.translated_text === null ? (
|
| 649 |
+
<div className="mt-2 flex items-center gap-2">
|
| 650 |
+
<span className="text-xs text-blue-400 animate-pulse">Processing…</span>
|
| 651 |
+
</div>
|
| 652 |
+
) : segment.translated_text && segment.translated_text !== segment.original_text ? (
|
| 653 |
+
<p className="text-blue-600 text-sm mt-2 italic">{segment.translated_text}</p>
|
| 654 |
+
) : (
|
| 655 |
+
<div className="mt-2 flex items-center gap-2">
|
| 656 |
+
<span className="text-xs text-gray-400">No English yet</span>
|
| 657 |
+
<button
|
| 658 |
+
onClick={() => retryTranslateOne(segment)}
|
| 659 |
+
disabled={retryingIds.has(segment.id)}
|
| 660 |
+
className="text-xs px-2 py-1 border rounded-md disabled:opacity-50 hover:bg-gray-50"
|
| 661 |
+
>
|
| 662 |
+
{retryingIds.has(segment.id) ? 'Translating…' : 'Translate'}
|
| 663 |
+
</button>
|
| 664 |
+
</div>
|
| 665 |
+
)}
|
| 666 |
+
</>
|
| 667 |
+
)}
|
| 668 |
+
<span className="text-xs text-gray-500 mt-2 block">
|
| 669 |
+
{new Date(segment.created_at).toLocaleTimeString()}
|
| 670 |
+
</span>
|
| 671 |
+
</div>
|
| 672 |
+
))}
|
| 673 |
+
{interimText && (
|
| 674 |
+
<div className="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
|
| 675 |
+
<p className="text-gray-700 italic">{interimText}</p>
|
| 676 |
+
<span className="text-xs text-gray-500 mt-2 block">Processing...</span>
|
| 677 |
+
</div>
|
| 678 |
+
)}
|
| 679 |
+
{segments.length === 0 && !interimText && (
|
| 680 |
+
<div className="text-center py-12 text-gray-500">
|
| 681 |
+
<Mic className="w-12 h-12 mx-auto mb-3 text-gray-400" />
|
| 682 |
+
<p>Start recording to see live transcriptions appear here</p>
|
| 683 |
+
</div>
|
| 684 |
+
)}
|
| 685 |
+
</div>
|
| 686 |
+
</div>
|
| 687 |
+
</div>
|
| 688 |
+
|
| 689 |
+
{showQR && group && (
|
| 690 |
+
<QRCodeDisplay
|
| 691 |
+
joinCode={group.join_code}
|
| 692 |
+
groupName={group.name}
|
| 693 |
+
onClose={() => setShowQR(false)}
|
| 694 |
+
/>
|
| 695 |
+
)}
|
| 696 |
+
</div>
|
| 697 |
+
);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
function ManualInput({ onSubmit, disabled }: { onSubmit: (text: string) => void | Promise<void>; disabled?: boolean }) {
|
| 701 |
+
const [text, setText] = useState('');
|
| 702 |
+
const [busy, setBusy] = useState(false);
|
| 703 |
+
const canSubmit = text.trim().length > 0 && !busy && !disabled;
|
| 704 |
+
return (
|
| 705 |
+
<div className="mt-4">
|
| 706 |
+
<label className="block text-sm text-gray-600 mb-1">Type a line to add manually</label>
|
| 707 |
+
<div className="flex gap-2">
|
| 708 |
+
<input
|
| 709 |
+
type="text"
|
| 710 |
+
value={text}
|
| 711 |
+
onChange={(e) => setText(e.target.value)}
|
| 712 |
+
onKeyDown={async (e) => {
|
| 713 |
+
if (e.key === 'Enter' && canSubmit) {
|
| 714 |
+
setBusy(true);
|
| 715 |
+
await onSubmit(text.trim());
|
| 716 |
+
setText('');
|
| 717 |
+
setBusy(false);
|
| 718 |
+
}
|
| 719 |
+
}}
|
| 720 |
+
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 721 |
+
placeholder="Hello everyone..."
|
| 722 |
+
disabled={disabled}
|
| 723 |
+
/>
|
| 724 |
+
<button
|
| 725 |
+
onClick={async () => {
|
| 726 |
+
if (!canSubmit) return;
|
| 727 |
+
setBusy(true);
|
| 728 |
+
await onSubmit(text.trim());
|
| 729 |
+
setText('');
|
| 730 |
+
setBusy(false);
|
| 731 |
+
}}
|
| 732 |
+
disabled={!canSubmit}
|
| 733 |
+
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700"
|
| 734 |
+
>
|
| 735 |
+
Add
|
| 736 |
+
</button>
|
| 737 |
+
</div>
|
| 738 |
+
</div>
|
| 739 |
+
);
|
| 740 |
+
}
|
src/components/JoinGroup.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { X, Loader2 } from 'lucide-react';
|
| 3 |
+
import { LocalDB } from '../lib/localdb';
|
| 4 |
+
import { useAuth } from '../contexts/useAuth';
|
| 5 |
+
import { useNavigate } from './NavigationHooks';
|
| 6 |
+
|
| 7 |
+
interface JoinGroupProps {
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function JoinGroup({ onClose }: JoinGroupProps) {
|
| 12 |
+
const { user } = useAuth();
|
| 13 |
+
const navigate = useNavigate();
|
| 14 |
+
const [joinCode, setJoinCode] = useState('');
|
| 15 |
+
const [loading, setLoading] = useState(false);
|
| 16 |
+
const [error, setError] = useState('');
|
| 17 |
+
|
| 18 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
if (!user) return;
|
| 21 |
+
|
| 22 |
+
setLoading(true);
|
| 23 |
+
setError('');
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const group = LocalDB.findActiveGroupByJoinCode(joinCode);
|
| 27 |
+
if (!group) {
|
| 28 |
+
setError('Invalid or expired join code');
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
LocalDB.addMember(group.id, user.id, false);
|
| 33 |
+
|
| 34 |
+
navigate(`/viewer/${group.id}`);
|
| 35 |
+
onClose();
|
| 36 |
+
} catch (err) {
|
| 37 |
+
setError(err instanceof Error ? err.message : 'Failed to join group');
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
| 45 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
| 46 |
+
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
| 47 |
+
<h2 className="text-2xl font-bold text-gray-900">Join a Session</h2>
|
| 48 |
+
<button
|
| 49 |
+
onClick={onClose}
|
| 50 |
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
| 51 |
+
>
|
| 52 |
+
<X className="w-6 h-6" />
|
| 53 |
+
</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
| 57 |
+
<div>
|
| 58 |
+
<label htmlFor="joinCode" className="block text-sm font-medium text-gray-700 mb-1">
|
| 59 |
+
Join Code
|
| 60 |
+
</label>
|
| 61 |
+
<input
|
| 62 |
+
id="joinCode"
|
| 63 |
+
type="text"
|
| 64 |
+
value={joinCode}
|
| 65 |
+
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
| 66 |
+
required
|
| 67 |
+
maxLength={6}
|
| 68 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent uppercase text-center text-2xl font-mono tracking-widest"
|
| 69 |
+
placeholder="ABC123"
|
| 70 |
+
/>
|
| 71 |
+
<p className="mt-2 text-sm text-gray-500">
|
| 72 |
+
Enter the 6-character code from the host
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{error && (
|
| 77 |
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
| 78 |
+
{error}
|
| 79 |
+
</div>
|
| 80 |
+
)}
|
| 81 |
+
|
| 82 |
+
<button
|
| 83 |
+
type="submit"
|
| 84 |
+
disabled={loading || joinCode.length !== 6}
|
| 85 |
+
className="w-full bg-green-600 text-white py-3 rounded-lg font-semibold hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
| 86 |
+
>
|
| 87 |
+
{loading ? (
|
| 88 |
+
<>
|
| 89 |
+
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
| 90 |
+
Joining...
|
| 91 |
+
</>
|
| 92 |
+
) : (
|
| 93 |
+
'Join Session'
|
| 94 |
+
)}
|
| 95 |
+
</button>
|
| 96 |
+
</form>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
src/components/MyGroups.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { LocalDB, Group as LocalGroup } from '../lib/localdb';
|
| 3 |
+
import { useAuth } from '../contexts/useAuth';
|
| 4 |
+
import { Radio, Users, Clock, Trash2 } from 'lucide-react';
|
| 5 |
+
import { useNavigate } from './NavigationHooks';
|
| 6 |
+
|
| 7 |
+
type UserGroup = LocalGroup & { isHost: boolean };
|
| 8 |
+
|
| 9 |
+
export function MyGroups() {
|
| 10 |
+
const { user } = useAuth();
|
| 11 |
+
const navigate = useNavigate();
|
| 12 |
+
const [groups, setGroups] = useState<UserGroup[]>([]);
|
| 13 |
+
const [loading, setLoading] = useState(true);
|
| 14 |
+
|
| 15 |
+
const loadGroups = useCallback(() => {
|
| 16 |
+
if (!user) return;
|
| 17 |
+
try {
|
| 18 |
+
const groupsForUser = LocalDB.getGroupsForUser(user.id) as UserGroup[];
|
| 19 |
+
setGroups(groupsForUser);
|
| 20 |
+
} finally {
|
| 21 |
+
setLoading(false);
|
| 22 |
+
}
|
| 23 |
+
}, [user]);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
if (!user) return;
|
| 27 |
+
loadGroups();
|
| 28 |
+
}, [user, loadGroups]);
|
| 29 |
+
|
| 30 |
+
// ...existing code...
|
| 31 |
+
|
| 32 |
+
const formatDate = (dateString: string) => {
|
| 33 |
+
const date = new Date(dateString);
|
| 34 |
+
return (
|
| 35 |
+
date.toLocaleDateString() +
|
| 36 |
+
' ' +
|
| 37 |
+
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 38 |
+
);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
if (loading) {
|
| 42 |
+
return (
|
| 43 |
+
<div className="bg-white rounded-xl shadow-md p-8">
|
| 44 |
+
<div className="animate-pulse space-y-4">
|
| 45 |
+
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
| 46 |
+
<div className="h-20 bg-gray-200 rounded"></div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (groups.length === 0) {
|
| 53 |
+
return (
|
| 54 |
+
<div className="bg-white rounded-xl shadow-md p-8 text-center">
|
| 55 |
+
<Radio className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
| 56 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Sessions Yet</h3>
|
| 57 |
+
<p className="text-gray-600">Create a session to host or join one with a code</p>
|
| 58 |
+
</div>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="bg-white rounded-xl shadow-md p-6">
|
| 64 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Sessions</h2>
|
| 65 |
+
<div className="space-y-3">
|
| 66 |
+
{groups.map((group) => (
|
| 67 |
+
<div
|
| 68 |
+
key={group.id}
|
| 69 |
+
className="w-full bg-gray-50 rounded-lg p-4 text-left group hover:bg-gray-100 transition-colors"
|
| 70 |
+
>
|
| 71 |
+
<div className="flex items-start justify-between">
|
| 72 |
+
<button
|
| 73 |
+
onClick={() =>
|
| 74 |
+
navigate(group.isHost ? `/host/${group.id}` : `/viewer/${group.id}`)
|
| 75 |
+
}
|
| 76 |
+
className="flex-1 text-left"
|
| 77 |
+
>
|
| 78 |
+
<div className="flex items-center mb-2">
|
| 79 |
+
<h3 className="text-lg font-semibold text-gray-900">{group.name}</h3>
|
| 80 |
+
{group.is_active && (
|
| 81 |
+
<span className="ml-3 px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
| 82 |
+
Active
|
| 83 |
+
</span>
|
| 84 |
+
)}
|
| 85 |
+
{group.isHost && (
|
| 86 |
+
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
|
| 87 |
+
Host
|
| 88 |
+
</span>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
<div className="flex items-center text-sm text-gray-600 space-x-4">
|
| 92 |
+
<span className="flex items-center">
|
| 93 |
+
<Clock className="w-4 h-4 mr-1" />
|
| 94 |
+
{formatDate(group.created_at)}
|
| 95 |
+
</span>
|
| 96 |
+
<span className="flex items-center">
|
| 97 |
+
<Users className="w-4 h-4 mr-1" />
|
| 98 |
+
Code: {group.join_code}
|
| 99 |
+
</span>
|
| 100 |
+
</div>
|
| 101 |
+
</button>
|
| 102 |
+
{group.isHost && (
|
| 103 |
+
<button
|
| 104 |
+
aria-label="Delete session"
|
| 105 |
+
title="Delete session"
|
| 106 |
+
onClick={() => {
|
| 107 |
+
if (
|
| 108 |
+
confirm('Delete this session? This will remove all local data for it.')
|
| 109 |
+
) {
|
| 110 |
+
LocalDB.deleteGroup(group.id);
|
| 111 |
+
setGroups((prev) => prev.filter((g) => g.id !== group.id));
|
| 112 |
+
}
|
| 113 |
+
}}
|
| 114 |
+
className="ml-3 p-2 rounded-lg text-red-600 hover:bg-red-50"
|
| 115 |
+
>
|
| 116 |
+
<Trash2 className="w-5 h-5" />
|
| 117 |
+
</button>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
src/components/Navigation.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
export interface NavigationContextType {
|
| 4 |
+
currentPath: string;
|
| 5 |
+
navigate: (path: string) => void;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
|
| 9 |
+
|
| 10 |
+
export function NavigationProvider({ children }: { children: any }) {
|
| 11 |
+
const [currentPath, setCurrentPath] = useState<string>(window.location.hash.slice(1) || '/');
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
const handleHashChange = () => {
|
| 15 |
+
setCurrentPath(window.location.hash.slice(1) || '/');
|
| 16 |
+
};
|
| 17 |
+
window.addEventListener('hashchange', handleHashChange);
|
| 18 |
+
return () => window.removeEventListener('hashchange', handleHashChange);
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const navigate = (path: string) => {
|
| 22 |
+
window.location.hash = path;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<NavigationContext.Provider value={{ currentPath, navigate }}>
|
| 27 |
+
{children}
|
| 28 |
+
</NavigationContext.Provider>
|
| 29 |
+
);
|
| 30 |
+
}
|
src/components/NavigationHooks.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from 'react';
|
| 2 |
+
import { NavigationContext, NavigationContextType } from './Navigation';
|
| 3 |
+
|
| 4 |
+
export function useNavigate() {
|
| 5 |
+
const context = useContext(NavigationContext) as NavigationContextType | undefined;
|
| 6 |
+
if (!context) {
|
| 7 |
+
throw new Error('useNavigate must be used within NavigationProvider');
|
| 8 |
+
}
|
| 9 |
+
return context.navigate;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function useCurrentPath() {
|
| 13 |
+
const context = useContext(NavigationContext) as NavigationContextType | undefined;
|
| 14 |
+
if (!context) {
|
| 15 |
+
throw new Error('useCurrentPath must be used within NavigationProvider');
|
| 16 |
+
}
|
| 17 |
+
return context.currentPath;
|
| 18 |
+
}
|
src/components/QRCodeDisplay.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { X } from 'lucide-react';
|
| 2 |
+
|
| 3 |
+
interface QRCodeDisplayProps {
|
| 4 |
+
joinCode: string;
|
| 5 |
+
groupName: string;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function QRCodeDisplay({ joinCode, groupName, onClose }: QRCodeDisplayProps) {
|
| 10 |
+
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(joinCode)}`;
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
| 14 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
| 15 |
+
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
| 16 |
+
<h2 className="text-2xl font-bold text-gray-900">Share Session</h2>
|
| 17 |
+
<button
|
| 18 |
+
onClick={onClose}
|
| 19 |
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
| 20 |
+
>
|
| 21 |
+
<X className="w-6 h-6" />
|
| 22 |
+
</button>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div className="p-6 text-center">
|
| 26 |
+
<p className="text-gray-600 mb-4">Scan this QR code to join</p>
|
| 27 |
+
<p className="text-xl font-bold text-gray-900 mb-6">{groupName}</p>
|
| 28 |
+
|
| 29 |
+
<div className="bg-gray-100 rounded-xl p-6 mb-6">
|
| 30 |
+
<img
|
| 31 |
+
src={qrCodeUrl}
|
| 32 |
+
alt="QR Code"
|
| 33 |
+
className="w-full max-w-xs mx-auto"
|
| 34 |
+
/>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div className="bg-blue-50 rounded-lg p-4">
|
| 38 |
+
<p className="text-sm text-gray-600 mb-2">Or enter this code:</p>
|
| 39 |
+
<p className="text-4xl font-mono font-bold text-blue-600">{joinCode}</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
}
|
src/components/ViewerView.tsx
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { LocalDB, Group, TranscriptionSegment } from '../lib/localdb';
|
| 3 |
+
import { getSegmentsForViewer } from '../lib/localdb';
|
| 4 |
+
import { useAuth } from '../contexts/useAuth';
|
| 5 |
+
import { useNavigate } from './NavigationHooks';
|
| 6 |
+
import { Radio, Users, ArrowLeft } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
interface ViewerViewProps {
|
| 9 |
+
groupId: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function ViewerView({ groupId }: ViewerViewProps) {
|
| 13 |
+
// Responsive design improvements applied; no unused code remains
|
| 14 |
+
// Investor Mode state
|
| 15 |
+
const [investorMode, setInvestorMode] = useState(false);
|
| 16 |
+
const [supportedPairs, setSupportedPairs] = useState<string[]>([]);
|
| 17 |
+
const [backendHealth, setBackendHealth] = useState<string>('');
|
| 18 |
+
|
| 19 |
+
// Fetch supported pairs and backend health for investor mode
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (investorMode) {
|
| 22 |
+
fetch('/models')
|
| 23 |
+
.then(res => res.json())
|
| 24 |
+
.then(data => setSupportedPairs(data.models || []));
|
| 25 |
+
fetch('/health')
|
| 26 |
+
.then(res => res.json())
|
| 27 |
+
.then(data => setBackendHealth(data.device || 'unknown'));
|
| 28 |
+
}
|
| 29 |
+
}, [investorMode]);
|
| 30 |
+
// Target language selection removed for reliability
|
| 31 |
+
// Target language state (persist per viewer)
|
| 32 |
+
const viewerId = 'viewer_' + (window.navigator.userAgent || 'default'); // Replace with real viewer ID if available
|
| 33 |
+
const { user } = useAuth();
|
| 34 |
+
const navigate = useNavigate();
|
| 35 |
+
const [group, setGroup] = useState<Group | null>(null);
|
| 36 |
+
const [segments, setSegments] = useState<TranscriptionSegment[]>([]);
|
| 37 |
+
const [memberCount, setMemberCount] = useState(0);
|
| 38 |
+
const [isActive, setIsActive] = useState(true);
|
| 39 |
+
|
| 40 |
+
const loadGroup = useCallback(async () => {
|
| 41 |
+
const data = LocalDB.getGroupById(groupId);
|
| 42 |
+
if (data) {
|
| 43 |
+
setGroup(data);
|
| 44 |
+
setIsActive(data.is_active);
|
| 45 |
+
} else {
|
| 46 |
+
navigate('/');
|
| 47 |
+
}
|
| 48 |
+
}, [groupId, navigate]);
|
| 49 |
+
|
| 50 |
+
const loadSegments = useCallback(async () => {
|
| 51 |
+
const data = getSegmentsForViewer(groupId, viewerId);
|
| 52 |
+
setSegments(data);
|
| 53 |
+
setTimeout(() => {
|
| 54 |
+
const container = document.getElementById('transcription-container');
|
| 55 |
+
if (container) {
|
| 56 |
+
container.scrollTop = container.scrollHeight;
|
| 57 |
+
}
|
| 58 |
+
}, 100);
|
| 59 |
+
}, [groupId]);
|
| 60 |
+
|
| 61 |
+
const loadMemberCount = useCallback(async () => {
|
| 62 |
+
setMemberCount(LocalDB.getMemberCount(groupId));
|
| 63 |
+
}, [groupId]);
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
if (!user) return;
|
| 67 |
+
|
| 68 |
+
loadGroup();
|
| 69 |
+
loadSegments();
|
| 70 |
+
loadMemberCount();
|
| 71 |
+
|
| 72 |
+
const unsubSeg = LocalDB.onSegmentsInserted(groupId, (_) => {
|
| 73 |
+
loadSegments();
|
| 74 |
+
setTimeout(() => {
|
| 75 |
+
const container = document.getElementById('transcription-container');
|
| 76 |
+
if (container) {
|
| 77 |
+
container.scrollTop = container.scrollHeight;
|
| 78 |
+
}
|
| 79 |
+
}, 100);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
const unsubGroup = LocalDB.onGroupUpdated(groupId, (updated) => {
|
| 83 |
+
setGroup(updated);
|
| 84 |
+
setIsActive(updated.is_active);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
const unsubMembers = LocalDB.onMembersChanged(groupId, () => {
|
| 88 |
+
loadMemberCount();
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
return () => {
|
| 92 |
+
unsubSeg();
|
| 93 |
+
unsubGroup();
|
| 94 |
+
unsubMembers();
|
| 95 |
+
};
|
| 96 |
+
}, [groupId, user, loadGroup, loadSegments, loadMemberCount]);
|
| 97 |
+
|
| 98 |
+
// ...existing code...
|
| 99 |
+
|
| 100 |
+
const leaveSession = async () => {
|
| 101 |
+
if (user) {
|
| 102 |
+
LocalDB.removeMember(groupId, user.id);
|
| 103 |
+
}
|
| 104 |
+
navigate('/');
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
if (!group) {
|
| 108 |
+
return (
|
| 109 |
+
<>
|
| 110 |
+
{/* Branding & Value Proposition Overlay */}
|
| 111 |
+
<div className="fixed top-0 left-0 w-full z-40 bg-gradient-to-r from-green-400 to-blue-500 text-white py-4 px-6 flex items-center justify-between shadow-lg animate-fade-in">
|
| 112 |
+
<div className="flex items-center gap-3">
|
| 113 |
+
<img src="/logo.svg" alt="Brand Logo" className="h-8 w-8 rounded-full shadow-md" />
|
| 114 |
+
<span className="font-extrabold text-xl tracking-wide">Live Multilingual Demo</span>
|
| 115 |
+
</div>
|
| 116 |
+
<span className="font-semibold text-sm">Break language barriers instantly in meetings, events, and broadcasts.</span>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="fixed top-4 right-4 z-50">
|
| 119 |
+
<button
|
| 120 |
+
className={`px-4 py-2 rounded-lg font-bold shadow-lg ${investorMode ? 'bg-yellow-400 text-black' : 'bg-gray-800 text-white'}`}
|
| 121 |
+
onClick={() => setInvestorMode(v => !v)}
|
| 122 |
+
>
|
| 123 |
+
{investorMode ? 'Exit Investor Mode' : 'Investor Mode'}
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
{investorMode && (
|
| 127 |
+
<div className="fixed top-16 right-4 z-50 bg-white border border-yellow-400 rounded-xl shadow-xl p-6 w-96 animate-fade-in">
|
| 128 |
+
<h2 className="text-2xl font-bold mb-2 text-yellow-600">Investor Pitch Mode</h2>
|
| 129 |
+
<p className="mb-4 text-gray-700">Break language barriers instantly in meetings, events, and broadcasts. Scalable, real-time, multilingual translation for global impact.</p>
|
| 130 |
+
<div className="mb-2">
|
| 131 |
+
<span className="font-semibold">Supported Language Pairs:</span>
|
| 132 |
+
<ul className="list-disc ml-6 text-sm mt-1">
|
| 133 |
+
{supportedPairs.map(pair => (
|
| 134 |
+
<li key={pair}>{pair}</li>
|
| 135 |
+
))}
|
| 136 |
+
</ul>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="mb-2">
|
| 139 |
+
<span className="font-semibold">Backend Device:</span> <span className="text-blue-600">{backendHealth}</span>
|
| 140 |
+
</div>
|
| 141 |
+
<div className="mb-2">
|
| 142 |
+
<span className="font-semibold">Live Analytics:</span>
|
| 143 |
+
<ul className="list-disc ml-6 text-sm mt-1">
|
| 144 |
+
<li>Instant transcription and translation</li>
|
| 145 |
+
<li>Real-time error handling and retry</li>
|
| 146 |
+
<li>Mobile & desktop friendly</li>
|
| 147 |
+
<li>Cloud-ready, scalable backend</li>
|
| 148 |
+
</ul>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="mt-4 text-center text-xs text-gray-500">
|
| 151 |
+
<span>Backend is cloud-ready and can scale to millions of users.</span>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
| 156 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
| 157 |
+
</div>
|
| 158 |
+
</>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
return (
|
| 163 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-green-50">
|
| 164 |
+
<div className="max-w-4xl mx-auto p-4">
|
| 165 |
+
<div className="bg-white rounded-xl shadow-lg mb-4">
|
| 166 |
+
<div className="p-6 border-b border-gray-200">
|
| 167 |
+
<div className="flex items-center justify-between mb-4">
|
| 168 |
+
<div className="flex items-center">
|
| 169 |
+
<button
|
| 170 |
+
onClick={leaveSession}
|
| 171 |
+
className="mr-4 p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
| 172 |
+
>
|
| 173 |
+
<ArrowLeft className="w-6 h-6 text-gray-600" />
|
| 174 |
+
</button>
|
| 175 |
+
<div>
|
| 176 |
+
<h1 className="text-2xl font-bold text-gray-900">{group.name}</h1>
|
| 177 |
+
<p className="text-sm text-gray-600">Viewer Mode</p>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="flex items-center space-x-3">
|
| 181 |
+
<div className="flex items-center bg-gray-100 rounded-lg px-3 py-2">
|
| 182 |
+
<Users className="w-5 h-5 text-gray-600 mr-2" />
|
| 183 |
+
<span className="font-semibold text-gray-900">{memberCount}</span>
|
| 184 |
+
</div>
|
| 185 |
+
{isActive ? (
|
| 186 |
+
<div className="flex items-center bg-green-100 text-green-700 px-3 py-2 rounded-lg">
|
| 187 |
+
<span className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></span>
|
| 188 |
+
<span className="text-sm font-medium">Live</span>
|
| 189 |
+
</div>
|
| 190 |
+
) : (
|
| 191 |
+
<div className="bg-gray-200 text-gray-700 px-3 py-2 rounded-lg">
|
| 192 |
+
<span className="text-sm font-medium">Ended</span>
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div className="mb-4 flex items-center gap-4">
|
| 201 |
+
<label className="text-sm font-semibold">Target Language:</label>
|
| 202 |
+
{/* Target language selection removed for reliability */}
|
| 203 |
+
</div>
|
| 204 |
+
<div className="bg-white rounded-xl shadow-lg p-6">
|
| 205 |
+
<div className="flex items-center justify-between mb-4">
|
| 206 |
+
<h2 className="text-xl font-bold text-gray-900">Live Transcription</h2>
|
| 207 |
+
{isActive && (
|
| 208 |
+
<div className="text-sm text-green-600 flex items-center">
|
| 209 |
+
<Radio className="w-4 h-4 mr-1 animate-pulse" />
|
| 210 |
+
Receiving updates
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<div
|
| 216 |
+
id="transcription-container"
|
| 217 |
+
className="space-y-3 max-h-[calc(100vh-300px)] overflow-y-auto"
|
| 218 |
+
>
|
| 219 |
+
{segments
|
| 220 |
+
.slice()
|
| 221 |
+
.sort((a, b) => a.sequence_number - b.sequence_number)
|
| 222 |
+
.map((segment) => (
|
| 223 |
+
<div
|
| 224 |
+
key={segment.id}
|
| 225 |
+
className="bg-gradient-to-r from-blue-50 to-green-50 rounded-lg p-4 shadow-sm border border-gray-200"
|
| 226 |
+
>
|
| 227 |
+
<p className="text-gray-900 text-lg leading-relaxed">{segment.original_text}</p>
|
| 228 |
+
{segment.translated_text === undefined ? (
|
| 229 |
+
<div className="mt-2 flex items-center gap-2">
|
| 230 |
+
<span className="text-xs text-blue-400 animate-pulse">Processing…</span>
|
| 231 |
+
</div>
|
| 232 |
+
) : segment.translated_text && segment.translated_text !== segment.original_text ? (
|
| 233 |
+
<p className="text-blue-600 mt-2 italic leading-relaxed">
|
| 234 |
+
{segment.translated_text}
|
| 235 |
+
</p>
|
| 236 |
+
) : (
|
| 237 |
+
<div className="mt-2 flex flex-col gap-2">
|
| 238 |
+
<span className="text-xs text-red-500">Translation failed or unavailable.</span>
|
| 239 |
+
<button
|
| 240 |
+
className="text-xs px-2 py-1 border rounded-md hover:bg-gray-50"
|
| 241 |
+
onClick={() => window.location.reload()}
|
| 242 |
+
title="Retry translation"
|
| 243 |
+
>
|
| 244 |
+
Retry
|
| 245 |
+
</button>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
<span className="text-xs text-gray-500 mt-2 block">
|
| 249 |
+
{new Date(segment.created_at).toLocaleTimeString()}
|
| 250 |
+
</span>
|
| 251 |
+
</div>
|
| 252 |
+
))}
|
| 253 |
+
|
| 254 |
+
{segments.length === 0 && (
|
| 255 |
+
<div className="text-center py-16 text-gray-500">
|
| 256 |
+
<Radio className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
| 257 |
+
<p className="text-lg font-medium">Waiting for transcription to start...</p>
|
| 258 |
+
<p className="text-sm mt-2">The host will begin broadcasting shortly</p>
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
{!isActive && segments.length > 0 && (
|
| 265 |
+
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center">
|
| 266 |
+
<p className="text-yellow-800 font-medium">This session has ended</p>
|
| 267 |
+
<p className="text-yellow-700 text-sm mt-1">You can still view the transcription above</p>
|
| 268 |
+
</div>
|
| 269 |
+
)}
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
);
|
| 273 |
+
}
|
src/contexts/AuthContext.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useEffect, useState } from 'react';
|
| 2 |
+
import { User } from '@supabase/supabase-js';
|
| 3 |
+
import { supabase } from '../lib/supabase';
|
| 4 |
+
|
| 5 |
+
// Temporary bypass for login/auth while developing. Set to false to restore auth.
|
| 6 |
+
// TODO: Remove this flag when enabling real authentication again.
|
| 7 |
+
const BYPASS_AUTH = true;
|
| 8 |
+
|
| 9 |
+
interface AuthContextType {
|
| 10 |
+
user: User | null;
|
| 11 |
+
loading: boolean;
|
| 12 |
+
signUp: (email: string, password: string) => Promise<void>;
|
| 13 |
+
signIn: (email: string, password: string) => Promise<void>;
|
| 14 |
+
signOut: () => Promise<void>;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
| 18 |
+
|
| 19 |
+
export function AuthProvider({ children }: { children: any }) {
|
| 20 |
+
const [user, setUser] = useState<User | null>(null);
|
| 21 |
+
const [loading, setLoading] = useState(true);
|
| 22 |
+
console.log('[AuthContext] Initial user:', user, 'loading:', loading);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
if (BYPASS_AUTH) {
|
| 26 |
+
// Minimal dummy user object to satisfy types and UI logic
|
| 27 |
+
// Casting to User to avoid pulling in all required fields
|
| 28 |
+
const dummyUser = { id: 'dev-user' } as unknown as User;
|
| 29 |
+
setUser(dummyUser);
|
| 30 |
+
setLoading(false);
|
| 31 |
+
console.log('[AuthContext] BYPASS_AUTH set user:', dummyUser);
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
supabase.auth.getSession().then(({ data: { session } }) => {
|
| 36 |
+
setUser(session?.user ?? null);
|
| 37 |
+
setLoading(false);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event: any, session: any) => {
|
| 41 |
+
(async () => {
|
| 42 |
+
setUser(session?.user ?? null);
|
| 43 |
+
})();
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
return () => subscription.unsubscribe();
|
| 47 |
+
}, []);
|
| 48 |
+
|
| 49 |
+
const signUp = async (email: string, password: string) => {
|
| 50 |
+
if (BYPASS_AUTH) return; // no-op while auth is bypassed
|
| 51 |
+
const { error } = await supabase.auth.signUp({
|
| 52 |
+
email,
|
| 53 |
+
password,
|
| 54 |
+
});
|
| 55 |
+
if (error) throw error;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const signIn = async (email: string, password: string) => {
|
| 59 |
+
if (BYPASS_AUTH) return; // no-op while auth is bypassed
|
| 60 |
+
const { error } = await supabase.auth.signInWithPassword({
|
| 61 |
+
email,
|
| 62 |
+
password,
|
| 63 |
+
});
|
| 64 |
+
if (error) throw error;
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const signOut = async () => {
|
| 68 |
+
if (BYPASS_AUTH) return; // no-op while auth is bypassed
|
| 69 |
+
const { error } = await supabase.auth.signOut();
|
| 70 |
+
if (error) throw error;
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<AuthContext.Provider value={{ user, loading, signUp, signIn, signOut }}>
|
| 75 |
+
{children}
|
| 76 |
+
</AuthContext.Provider>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
src/contexts/useAuth.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from 'react';
|
| 2 |
+
import { AuthContext } from './AuthContext';
|
| 3 |
+
|
| 4 |
+
export function useAuth() {
|
| 5 |
+
const context = useContext(AuthContext);
|
| 6 |
+
if (context === undefined) {
|
| 7 |
+
throw new Error('useAuth must be used within an AuthProvider');
|
| 8 |
+
}
|
| 9 |
+
return context;
|
| 10 |
+
}
|
src/index.css
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
src/lib/api.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface TranslateRequest {
|
| 2 |
+
text: string;
|
| 3 |
+
source_language: string;
|
| 4 |
+
target_language: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export interface PreloadRequest {
|
| 8 |
+
source_language: string;
|
| 9 |
+
target_language: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const ENV_BASE = (import.meta as any).env?.VITE_API_URL as string | undefined;
|
| 13 |
+
const IS_PROD = (import.meta as any).env?.PROD as boolean | undefined;
|
| 14 |
+
// Dev default hits local FastAPI; prod default uses same-origin (Space serves both)
|
| 15 |
+
// Always talk to '/api' to avoid any collision with static frontend routes.
|
| 16 |
+
const DEFAULT_BASE = IS_PROD
|
| 17 |
+
? (typeof window !== 'undefined' ? `${window.location.origin}/api` : undefined)
|
| 18 |
+
: 'http://127.0.0.1:8000/api';
|
| 19 |
+
const API_BASE: string | undefined = ENV_BASE ?? DEFAULT_BASE;
|
| 20 |
+
|
| 21 |
+
export async function translateText(req: TranslateRequest): Promise<string | null> {
|
| 22 |
+
if (!API_BASE) return null;
|
| 23 |
+
try {
|
| 24 |
+
const res = await fetch(`${API_BASE}/translate`, {
|
| 25 |
+
method: 'POST',
|
| 26 |
+
headers: { 'Content-Type': 'application/json' },
|
| 27 |
+
body: JSON.stringify(req),
|
| 28 |
+
});
|
| 29 |
+
if (!res.ok) return null;
|
| 30 |
+
const data = await res.json();
|
| 31 |
+
return (data && data.translated_text) || null;
|
| 32 |
+
} catch (e) {
|
| 33 |
+
return null;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export async function apiHealth(): Promise<boolean> {
|
| 38 |
+
if (!API_BASE) return false;
|
| 39 |
+
try {
|
| 40 |
+
const res = await fetch(`${API_BASE}/health`);
|
| 41 |
+
return res.ok;
|
| 42 |
+
} catch {
|
| 43 |
+
return false;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export async function preloadModel(req: PreloadRequest): Promise<boolean> {
|
| 48 |
+
if (!API_BASE) return false;
|
| 49 |
+
try {
|
| 50 |
+
const res = await fetch(`${API_BASE}/preload`, {
|
| 51 |
+
method: 'POST',
|
| 52 |
+
headers: { 'Content-Type': 'application/json' },
|
| 53 |
+
body: JSON.stringify(req),
|
| 54 |
+
});
|
| 55 |
+
if (!res.ok) return false;
|
| 56 |
+
const data = await res.json();
|
| 57 |
+
return !!data?.ok;
|
| 58 |
+
} catch {
|
| 59 |
+
return false;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function transcribeAudio(file: File, language?: string): Promise<{ text: string; language?: string } | null> {
|
| 64 |
+
if (!API_BASE) return null;
|
| 65 |
+
try {
|
| 66 |
+
const form = new FormData();
|
| 67 |
+
form.append('audio', file);
|
| 68 |
+
if (language) form.append('language', language);
|
| 69 |
+
const res = await fetch(`${API_BASE}/transcribe`, {
|
| 70 |
+
method: 'POST',
|
| 71 |
+
body: form,
|
| 72 |
+
});
|
| 73 |
+
if (!res.ok) return null;
|
| 74 |
+
const data = await res.json();
|
| 75 |
+
if (typeof data?.text === 'string') return { text: data.text, language: data.language };
|
| 76 |
+
return null;
|
| 77 |
+
} catch {
|
| 78 |
+
return null;
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function sttStatus(): Promise<boolean> {
|
| 83 |
+
if (!API_BASE) return false;
|
| 84 |
+
try {
|
| 85 |
+
const res = await fetch(`${API_BASE}/stt_status`);
|
| 86 |
+
if (!res.ok) return false;
|
| 87 |
+
const data = await res.json();
|
| 88 |
+
return !!data?.available;
|
| 89 |
+
} catch {
|
| 90 |
+
return false;
|
| 91 |
+
}
|
| 92 |
+
}
|
src/lib/localdb.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Use LocalDB.getSegments and TranscriptionSegment type from this file
|
| 2 |
+
// Store per-viewer target language preference
|
| 3 |
+
export function setViewerLanguage(viewerId: string, language: string) {
|
| 4 |
+
localStorage.setItem(`viewer_language_${viewerId}`, language);
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function getViewerLanguage(viewerId: string): string {
|
| 8 |
+
return localStorage.getItem(`viewer_language_${viewerId}`) || 'en';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
// Optionally, update segment retrieval to use viewer's language
|
| 12 |
+
// Example usage: getSegmentsForViewer(groupId, viewerId)
|
| 13 |
+
export function getSegmentsForViewer(groupId: string, viewerId: string) {
|
| 14 |
+
const segments: TranscriptionSegment[] = LocalDB.getSegments(groupId);
|
| 15 |
+
const lang = getViewerLanguage(viewerId);
|
| 16 |
+
// Filter or map segments to show translation in viewer's language
|
| 17 |
+
return segments.map((seg: TranscriptionSegment) => ({
|
| 18 |
+
...seg,
|
| 19 |
+
translated_text: seg.translations?.[lang] || seg.translated_text || seg.original_text,
|
| 20 |
+
}));
|
| 21 |
+
}
|
| 22 |
+
// Simple local data store using localStorage + in-memory events.
|
| 23 |
+
// This replaces the need for any backend. Data is stored per-browser.
|
| 24 |
+
// Cross-tab realtime: uses BroadcastChannel when available, with
|
| 25 |
+
// a localStorage 'storage' event fallback so viewers in another tab/window
|
| 26 |
+
// get immediate updates.
|
| 27 |
+
|
| 28 |
+
export interface Group {
|
| 29 |
+
id: string;
|
| 30 |
+
name: string;
|
| 31 |
+
host_id: string;
|
| 32 |
+
join_code: string;
|
| 33 |
+
source_language: string;
|
| 34 |
+
target_language: string;
|
| 35 |
+
is_active: boolean;
|
| 36 |
+
created_at: string;
|
| 37 |
+
expires_at: string;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface GroupMember {
|
| 41 |
+
id: string;
|
| 42 |
+
group_id: string;
|
| 43 |
+
user_id: string;
|
| 44 |
+
joined_at: string;
|
| 45 |
+
is_host: boolean;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface TranscriptionSegment {
|
| 49 |
+
id: string;
|
| 50 |
+
group_id: string;
|
| 51 |
+
original_text: string;
|
| 52 |
+
translated_text: string | null;
|
| 53 |
+
sequence_number: number;
|
| 54 |
+
created_at: string;
|
| 55 |
+
created_by: string;
|
| 56 |
+
// Map of language code to translated text
|
| 57 |
+
translations?: { [lang: string]: string };
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Storage keys
|
| 61 |
+
const K = {
|
| 62 |
+
groups: 'lt_groups',
|
| 63 |
+
members: 'lt_group_members',
|
| 64 |
+
segments: 'lt_segments',
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
function readArray<T>(key: string): T[] {
|
| 68 |
+
try {
|
| 69 |
+
const raw = localStorage.getItem(key);
|
| 70 |
+
return raw ? (JSON.parse(raw) as T[]) : [];
|
| 71 |
+
} catch {
|
| 72 |
+
return [];
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function writeArray<T>(key: string, arr: T[]) {
|
| 77 |
+
localStorage.setItem(key, JSON.stringify(arr));
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function uuid() {
|
| 81 |
+
// Use crypto if available for stable unique ids
|
| 82 |
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
| 83 |
+
return (crypto as any).randomUUID();
|
| 84 |
+
}
|
| 85 |
+
return 'id-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Event Emitter for simple realtime within the same tab
|
| 89 |
+
type Unsubscribe = () => void;
|
| 90 |
+
const listeners = {
|
| 91 |
+
segmentsInserted: new Map<string, Set<(segment: TranscriptionSegment) => void>>(),
|
| 92 |
+
segmentsUpdated: new Map<string, Set<(segment: TranscriptionSegment) => void>>(),
|
| 93 |
+
groupUpdated: new Map<string, Set<(group: Group) => void>>(),
|
| 94 |
+
membersChanged: new Map<string, Set<() => void>>(),
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
type DBEvent =
|
| 98 |
+
| { type: 'segmentInserted'; groupId: string; payload: TranscriptionSegment }
|
| 99 |
+
| { type: 'segmentUpdated'; groupId: string; payload: TranscriptionSegment }
|
| 100 |
+
| { type: 'groupUpdated'; groupId: string; payload: Group }
|
| 101 |
+
| { type: 'membersChanged'; groupId: string };
|
| 102 |
+
|
| 103 |
+
// Broadcast channel (if supported)
|
| 104 |
+
const bc: BroadcastChannel | null =
|
| 105 |
+
typeof window !== 'undefined' && 'BroadcastChannel' in window
|
| 106 |
+
? new (window as any).BroadcastChannel('lt_events')
|
| 107 |
+
: null;
|
| 108 |
+
|
| 109 |
+
function broadcast(evt: DBEvent) {
|
| 110 |
+
try {
|
| 111 |
+
bc?.postMessage(evt);
|
| 112 |
+
} catch {
|
| 113 |
+
// ignore cross-tab post failures
|
| 114 |
+
}
|
| 115 |
+
try {
|
| 116 |
+
// storage event fallback (fires in other tabs only)
|
| 117 |
+
localStorage.setItem('lt_event', JSON.stringify({ ...evt, ts: Date.now() }));
|
| 118 |
+
} catch {
|
| 119 |
+
// ignore storage fallback errors (quota/unsupported)
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function handleEvent(evt: DBEvent) {
|
| 124 |
+
switch (evt.type) {
|
| 125 |
+
case 'segmentInserted':
|
| 126 |
+
emitSegmentInserted(evt.groupId, evt.payload);
|
| 127 |
+
break;
|
| 128 |
+
case 'segmentUpdated':
|
| 129 |
+
emitSegmentUpdated(evt.groupId, evt.payload);
|
| 130 |
+
break;
|
| 131 |
+
case 'groupUpdated':
|
| 132 |
+
emitGroupUpdated(evt.groupId, evt.payload);
|
| 133 |
+
break;
|
| 134 |
+
case 'membersChanged':
|
| 135 |
+
emitMembersChanged(evt.groupId);
|
| 136 |
+
break;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Wire listeners for cross-tab communication once (module load)
|
| 141 |
+
if (bc) {
|
| 142 |
+
try {
|
| 143 |
+
bc.onmessage = (e: MessageEvent<DBEvent>) => {
|
| 144 |
+
const data = e.data;
|
| 145 |
+
if (data && typeof data === 'object' && 'type' in data) handleEvent(data);
|
| 146 |
+
};
|
| 147 |
+
} catch {
|
| 148 |
+
// ignore BroadcastChannel wiring errors (unsupported env)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
try {
|
| 152 |
+
window.addEventListener('storage', (e: StorageEvent) => {
|
| 153 |
+
if (e.key !== 'lt_event' || !e.newValue) return;
|
| 154 |
+
try {
|
| 155 |
+
const data = JSON.parse(e.newValue) as DBEvent & { ts?: number };
|
| 156 |
+
if (data && typeof data === 'object' && 'type' in data) handleEvent(data);
|
| 157 |
+
} catch {
|
| 158 |
+
// ignore malformed storage events
|
| 159 |
+
}
|
| 160 |
+
});
|
| 161 |
+
} catch {
|
| 162 |
+
// ignore storage event wiring errors (non-browser env)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function emitSegmentInserted(groupId: string, seg: TranscriptionSegment) {
|
| 166 |
+
const set = listeners.segmentsInserted.get(groupId);
|
| 167 |
+
set?.forEach((cb) => cb(seg));
|
| 168 |
+
// cross-tab
|
| 169 |
+
broadcast({ type: 'segmentInserted', groupId, payload: seg });
|
| 170 |
+
}
|
| 171 |
+
function emitSegmentUpdated(groupId: string, seg: TranscriptionSegment) {
|
| 172 |
+
const set = listeners.segmentsUpdated.get(groupId);
|
| 173 |
+
set?.forEach((cb) => cb(seg));
|
| 174 |
+
// cross-tab
|
| 175 |
+
broadcast({ type: 'segmentUpdated', groupId, payload: seg });
|
| 176 |
+
}
|
| 177 |
+
function emitGroupUpdated(groupId: string, group: Group) {
|
| 178 |
+
const set = listeners.groupUpdated.get(groupId);
|
| 179 |
+
set?.forEach((cb) => cb(group));
|
| 180 |
+
// cross-tab
|
| 181 |
+
broadcast({ type: 'groupUpdated', groupId, payload: group });
|
| 182 |
+
}
|
| 183 |
+
function emitMembersChanged(groupId: string) {
|
| 184 |
+
const set = listeners.membersChanged.get(groupId);
|
| 185 |
+
set?.forEach((cb) => cb());
|
| 186 |
+
// cross-tab
|
| 187 |
+
broadcast({ type: 'membersChanged', groupId });
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export const LocalDB = {
|
| 191 |
+
// Groups
|
| 192 |
+
createGroup(args: {
|
| 193 |
+
name: string;
|
| 194 |
+
hostId: string;
|
| 195 |
+
joinCode: string;
|
| 196 |
+
sourceLanguage: string;
|
| 197 |
+
targetLanguage: string;
|
| 198 |
+
}): Group {
|
| 199 |
+
const groups = readArray<Group>(K.groups);
|
| 200 |
+
const now = new Date().toISOString();
|
| 201 |
+
const group: Group = {
|
| 202 |
+
id: uuid(),
|
| 203 |
+
name: args.name,
|
| 204 |
+
host_id: args.hostId,
|
| 205 |
+
join_code: args.joinCode,
|
| 206 |
+
source_language: args.sourceLanguage,
|
| 207 |
+
target_language: args.targetLanguage,
|
| 208 |
+
is_active: true,
|
| 209 |
+
created_at: now,
|
| 210 |
+
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 211 |
+
};
|
| 212 |
+
groups.unshift(group);
|
| 213 |
+
writeArray(K.groups, groups);
|
| 214 |
+
emitGroupUpdated(group.id, group);
|
| 215 |
+
return group;
|
| 216 |
+
},
|
| 217 |
+
setGroupActive(groupId: string, active: boolean) {
|
| 218 |
+
const groups = readArray<Group>(K.groups);
|
| 219 |
+
const idx = groups.findIndex((g) => g.id === groupId);
|
| 220 |
+
if (idx >= 0) {
|
| 221 |
+
groups[idx] = { ...groups[idx], is_active: active };
|
| 222 |
+
writeArray(K.groups, groups);
|
| 223 |
+
emitGroupUpdated(groupId, groups[idx]);
|
| 224 |
+
}
|
| 225 |
+
},
|
| 226 |
+
getGroupById(groupId: string): Group | null {
|
| 227 |
+
const groups = readArray<Group>(K.groups);
|
| 228 |
+
return groups.find((g) => g.id === groupId) || null;
|
| 229 |
+
},
|
| 230 |
+
findActiveGroupByJoinCode(code: string): Group | null {
|
| 231 |
+
const groups = readArray<Group>(K.groups);
|
| 232 |
+
const c = code.toUpperCase();
|
| 233 |
+
return groups.find((g) => g.join_code === c && g.is_active) || null;
|
| 234 |
+
},
|
| 235 |
+
generateUniqueJoinCode(): string {
|
| 236 |
+
const groups = readArray<Group>(K.groups);
|
| 237 |
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
| 238 |
+
// Try a few times to avoid collisions
|
| 239 |
+
for (let attempt = 0; attempt < 20; attempt++) {
|
| 240 |
+
let code = '';
|
| 241 |
+
for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
| 242 |
+
if (!groups.some((g) => g.join_code === code)) return code;
|
| 243 |
+
}
|
| 244 |
+
return Math.random().toString(36).slice(2, 8).toUpperCase();
|
| 245 |
+
},
|
| 246 |
+
deleteGroup(groupId: string) {
|
| 247 |
+
// remove group
|
| 248 |
+
const groups = readArray<Group>(K.groups).filter((g) => g.id !== groupId);
|
| 249 |
+
writeArray(K.groups, groups);
|
| 250 |
+
// remove members
|
| 251 |
+
const members = readArray<GroupMember>(K.members).filter((m) => m.group_id !== groupId);
|
| 252 |
+
writeArray(K.members, members);
|
| 253 |
+
emitMembersChanged(groupId);
|
| 254 |
+
// remove segments
|
| 255 |
+
const segs = readArray<TranscriptionSegment>(K.segments).filter((s) => s.group_id !== groupId);
|
| 256 |
+
writeArray(K.segments, segs);
|
| 257 |
+
},
|
| 258 |
+
|
| 259 |
+
// Members
|
| 260 |
+
addMember(groupId: string, userId: string, isHost: boolean): GroupMember {
|
| 261 |
+
const members = readArray<GroupMember>(K.members);
|
| 262 |
+
const existing = members.find((m) => m.group_id === groupId && m.user_id === userId);
|
| 263 |
+
if (existing) return existing;
|
| 264 |
+
const member: GroupMember = {
|
| 265 |
+
id: uuid(),
|
| 266 |
+
group_id: groupId,
|
| 267 |
+
user_id: userId,
|
| 268 |
+
is_host: isHost,
|
| 269 |
+
joined_at: new Date().toISOString(),
|
| 270 |
+
};
|
| 271 |
+
members.push(member);
|
| 272 |
+
writeArray(K.members, members);
|
| 273 |
+
emitMembersChanged(groupId);
|
| 274 |
+
return member;
|
| 275 |
+
},
|
| 276 |
+
removeMember(groupId: string, userId: string) {
|
| 277 |
+
const members = readArray<GroupMember>(K.members);
|
| 278 |
+
const next = members.filter((m) => !(m.group_id === groupId && m.user_id === userId));
|
| 279 |
+
writeArray(K.members, next);
|
| 280 |
+
emitMembersChanged(groupId);
|
| 281 |
+
},
|
| 282 |
+
getMemberCount(groupId: string): number {
|
| 283 |
+
const members = readArray<GroupMember>(K.members);
|
| 284 |
+
return members.filter((m) => m.group_id === groupId).length;
|
| 285 |
+
},
|
| 286 |
+
getGroupsForUser(userId: string): (Group & { isHost: boolean })[] {
|
| 287 |
+
const members = readArray<GroupMember>(K.members).filter((m) => m.user_id === userId);
|
| 288 |
+
const ids = members.map((m) => m.group_id);
|
| 289 |
+
const groups = readArray<Group>(K.groups).filter((g) => ids.includes(g.id));
|
| 290 |
+
return groups
|
| 291 |
+
.map((g) => ({ ...g, isHost: !!members.find((m) => m.group_id === g.id)?.is_host }))
|
| 292 |
+
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
| 293 |
+
},
|
| 294 |
+
|
| 295 |
+
// Segments
|
| 296 |
+
getSegments(groupId: string): TranscriptionSegment[] {
|
| 297 |
+
const segs = readArray<TranscriptionSegment>(K.segments)
|
| 298 |
+
.filter((s) => s.group_id === groupId)
|
| 299 |
+
.sort((a, b) => a.sequence_number - b.sequence_number);
|
| 300 |
+
return segs;
|
| 301 |
+
},
|
| 302 |
+
addSegment(args: {
|
| 303 |
+
groupId: string;
|
| 304 |
+
originalText: string;
|
| 305 |
+
translatedText: string | null;
|
| 306 |
+
sequenceNumber: number;
|
| 307 |
+
createdBy: string;
|
| 308 |
+
}): TranscriptionSegment {
|
| 309 |
+
const segs = readArray<TranscriptionSegment>(K.segments);
|
| 310 |
+
const seg: TranscriptionSegment = {
|
| 311 |
+
id: uuid(),
|
| 312 |
+
group_id: args.groupId,
|
| 313 |
+
original_text: args.originalText,
|
| 314 |
+
translated_text: args.translatedText,
|
| 315 |
+
sequence_number: args.sequenceNumber,
|
| 316 |
+
created_at: new Date().toISOString(),
|
| 317 |
+
created_by: args.createdBy,
|
| 318 |
+
};
|
| 319 |
+
segs.push(seg);
|
| 320 |
+
writeArray(K.segments, segs);
|
| 321 |
+
emitSegmentInserted(args.groupId, seg);
|
| 322 |
+
return seg;
|
| 323 |
+
},
|
| 324 |
+
// Update just the translated_text for an existing segment
|
| 325 |
+
updateSegmentTranslation(groupId: string, segmentId: string, translatedText: string | null): TranscriptionSegment | null {
|
| 326 |
+
const segs = readArray<TranscriptionSegment>(K.segments);
|
| 327 |
+
const idx = segs.findIndex((s) => s.id === segmentId && s.group_id === groupId);
|
| 328 |
+
if (idx === -1) return null;
|
| 329 |
+
const updated: TranscriptionSegment = { ...segs[idx], translated_text: translatedText };
|
| 330 |
+
segs[idx] = updated;
|
| 331 |
+
writeArray(K.segments, segs);
|
| 332 |
+
// Broadcast update so viewers/other tabs see the new translation immediately
|
| 333 |
+
emitSegmentUpdated(groupId, updated);
|
| 334 |
+
return updated;
|
| 335 |
+
},
|
| 336 |
+
|
| 337 |
+
// Subscriptions (per-group)
|
| 338 |
+
onSegmentsInserted(groupId: string, cb: (seg: TranscriptionSegment) => void): Unsubscribe {
|
| 339 |
+
const set = listeners.segmentsInserted.get(groupId) || new Set();
|
| 340 |
+
set.add(cb);
|
| 341 |
+
listeners.segmentsInserted.set(groupId, set);
|
| 342 |
+
return () => {
|
| 343 |
+
const s = listeners.segmentsInserted.get(groupId);
|
| 344 |
+
s?.delete(cb);
|
| 345 |
+
};
|
| 346 |
+
},
|
| 347 |
+
onSegmentUpdated(groupId: string, cb: (seg: TranscriptionSegment) => void): Unsubscribe {
|
| 348 |
+
const set = listeners.segmentsUpdated.get(groupId) || new Set();
|
| 349 |
+
set.add(cb);
|
| 350 |
+
listeners.segmentsUpdated.set(groupId, set);
|
| 351 |
+
return () => {
|
| 352 |
+
const s = listeners.segmentsUpdated.get(groupId);
|
| 353 |
+
s?.delete(cb);
|
| 354 |
+
};
|
| 355 |
+
},
|
| 356 |
+
onGroupUpdated(groupId: string, cb: (group: Group) => void): Unsubscribe {
|
| 357 |
+
const set = listeners.groupUpdated.get(groupId) || new Set();
|
| 358 |
+
set.add(cb);
|
| 359 |
+
listeners.groupUpdated.set(groupId, set);
|
| 360 |
+
return () => {
|
| 361 |
+
const s = listeners.groupUpdated.get(groupId);
|
| 362 |
+
s?.delete(cb);
|
| 363 |
+
};
|
| 364 |
+
},
|
| 365 |
+
onMembersChanged(groupId: string, cb: () => void): Unsubscribe {
|
| 366 |
+
const set = listeners.membersChanged.get(groupId) || new Set();
|
| 367 |
+
set.add(cb);
|
| 368 |
+
listeners.membersChanged.set(groupId, set);
|
| 369 |
+
return () => {
|
| 370 |
+
const s = listeners.membersChanged.get(groupId);
|
| 371 |
+
s?.delete(cb);
|
| 372 |
+
};
|
| 373 |
+
},
|
| 374 |
+
// Export a group's data for backup (so user can delete later if storage is full)
|
| 375 |
+
exportGroup(groupId: string): {
|
| 376 |
+
group: Group | null;
|
| 377 |
+
members: GroupMember[];
|
| 378 |
+
segments: TranscriptionSegment[];
|
| 379 |
+
exportedAt: string;
|
| 380 |
+
} {
|
| 381 |
+
const group = LocalDB.getGroupById(groupId);
|
| 382 |
+
const members = readArray<GroupMember>(K.members).filter((m) => m.group_id === groupId);
|
| 383 |
+
const segments = LocalDB.getSegments(groupId);
|
| 384 |
+
return { group, members, segments, exportedAt: new Date().toISOString() };
|
| 385 |
+
},
|
| 386 |
+
// Rough estimate of bytes used by our keys in localStorage
|
| 387 |
+
getStorageBytes(): number {
|
| 388 |
+
let total = 0;
|
| 389 |
+
for (const key of [K.groups, K.members, K.segments]) {
|
| 390 |
+
try {
|
| 391 |
+
const v = localStorage.getItem(key);
|
| 392 |
+
if (v) total += key.length + v.length;
|
| 393 |
+
} catch {
|
| 394 |
+
// ignore storage access errors
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
return total;
|
| 398 |
+
},
|
| 399 |
+
};
|
src/lib/supabase.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Local stub replacing Supabase client so the app can run fully offline/local.
|
| 2 |
+
// The real client is not needed when using the local data store.
|
| 3 |
+
// Only minimal shape is provided to satisfy imports from AuthContext when auth bypass is enabled.
|
| 4 |
+
export const supabase = {
|
| 5 |
+
auth: {
|
| 6 |
+
async getSession() {
|
| 7 |
+
return { data: { session: null }, error: null } as any;
|
| 8 |
+
},
|
| 9 |
+
onAuthStateChange(_cb: any) {
|
| 10 |
+
return { data: { subscription: { unsubscribe() {} } } } as any;
|
| 11 |
+
},
|
| 12 |
+
async signUp(_args: any) {
|
| 13 |
+
return { data: null, error: null } as any;
|
| 14 |
+
},
|
| 15 |
+
async signInWithPassword(_args: any) {
|
| 16 |
+
return { data: null, error: null } as any;
|
| 17 |
+
},
|
| 18 |
+
async signOut() {
|
| 19 |
+
return { error: null } as any;
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
} as const;
|
| 23 |
+
|
| 24 |
+
export interface Group {
|
| 25 |
+
id: string;
|
| 26 |
+
name: string;
|
| 27 |
+
host_id: string;
|
| 28 |
+
join_code: string;
|
| 29 |
+
source_language: string;
|
| 30 |
+
target_language: string;
|
| 31 |
+
is_active: boolean;
|
| 32 |
+
created_at: string;
|
| 33 |
+
expires_at: string;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface GroupMember {
|
| 37 |
+
id: string;
|
| 38 |
+
group_id: string;
|
| 39 |
+
user_id: string;
|
| 40 |
+
joined_at: string;
|
| 41 |
+
is_host: boolean;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface TranscriptionSegment {
|
| 45 |
+
id: string;
|
| 46 |
+
group_id: string;
|
| 47 |
+
original_text: string;
|
| 48 |
+
translated_text: string | null;
|
| 49 |
+
sequence_number: number;
|
| 50 |
+
created_at: string;
|
| 51 |
+
created_by: string;
|
| 52 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react';
|
| 2 |
+
import { createRoot } from 'react-dom/client';
|
| 3 |
+
import App from './App.tsx';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>
|
| 10 |
+
);
|
src/useAuth.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useContext } from 'react';
|
| 2 |
+
import { AuthContext } from './contexts/AuthContext';
|
| 3 |
+
|
| 4 |
+
export function useAuth() {
|
| 5 |
+
const context = useContext(AuthContext);
|
| 6 |
+
if (!context) {
|
| 7 |
+
throw new Error('useAuth must be used within an AuthProvider');
|
| 8 |
+
}
|
| 9 |
+
return context;
|
| 10 |
+
}
|
src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
tailwind.config.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
| 4 |
+
theme: {
|
| 5 |
+
extend: {},
|
| 6 |
+
},
|
| 7 |
+
plugins: [],
|
| 8 |
+
};
|
tsconfig.app.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
|
| 9 |
+
/* Bundler mode */
|
| 10 |
+
"moduleResolution": "bundler",
|
| 11 |
+
"allowImportingTsExtensions": true,
|
| 12 |
+
"isolatedModules": true,
|
| 13 |
+
"moduleDetection": "force",
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
"jsx": "react-jsx",
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"noFallthroughCasesInSwitch": true
|
| 22 |
+
},
|
| 23 |
+
"include": ["src"]
|
| 24 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
tsconfig.node.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["ES2023"],
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
|
| 8 |
+
/* Bundler mode */
|
| 9 |
+
"moduleResolution": "bundler",
|
| 10 |
+
"allowImportingTsExtensions": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"moduleDetection": "force",
|
| 13 |
+
"noEmit": true,
|
| 14 |
+
|
| 15 |
+
/* Linting */
|
| 16 |
+
"strict": true,
|
| 17 |
+
"noUnusedLocals": true,
|
| 18 |
+
"noUnusedParameters": true,
|
| 19 |
+
"noFallthroughCasesInSwitch": true
|
| 20 |
+
},
|
| 21 |
+
"include": ["vite.config.ts"]
|
| 22 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
// https://vitejs.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
optimizeDeps: {
|
| 8 |
+
exclude: ['lucide-react'],
|
| 9 |
+
},
|
| 10 |
+
});
|