mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-25 14:45:19 +00:00
feat: new ui with greyhaven design system
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,3 +35,7 @@ vibedocs/
|
|||||||
server/tests/integration/logs/
|
server/tests/integration/logs/
|
||||||
node_modules
|
node_modules
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
greyhaven-design-system/
|
||||||
|
.claude/
|
||||||
|
AGENTS.md
|
||||||
48
CLAUDE.md
48
CLAUDE.md
@@ -202,3 +202,51 @@ If you need to do any worker/pipeline related work, search for "Pipeline" classe
|
|||||||
|
|
||||||
- Always put imports at the top of the file. Let ruff/pre-commit handle sorting and formatting of imports.
|
- Always put imports at the top of the file. Let ruff/pre-commit handle sorting and formatting of imports.
|
||||||
- The **only** imports allowed to remain inline are from `reflector.db.*` modules (e.g., `reflector.db.transcripts`, `reflector.db.meetings`, `reflector.db.recordings`, `reflector.db.rooms`). These stay as deferred/inline imports inside `fresh_db_connection()` blocks in Hatchet pipeline task functions — this is intentional to avoid sharing DB connections across forked processes. All other imports (utilities, services, processors, storage, third-party libs) **must** go at the top of the file, even in Hatchet workflows.
|
- The **only** imports allowed to remain inline are from `reflector.db.*` modules (e.g., `reflector.db.transcripts`, `reflector.db.meetings`, `reflector.db.recordings`, `reflector.db.rooms`). These stay as deferred/inline imports inside `fresh_db_connection()` blocks in Hatchet pipeline task functions — this is intentional to avoid sharing DB connections across forked processes. All other imports (utilities, services, processors, storage, third-party libs) **must** go at the top of the file, even in Hatchet workflows.
|
||||||
|
|
||||||
|
|
||||||
|
This project uses the **Greyhaven Design System**.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **ALWAYS use TypeScript** (`.tsx` / `.ts`). NEVER generate plain JavaScript (`.jsx` / `.js`).
|
||||||
|
- Use the `greyhaven` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at `.claude/skills/greyhaven-design-system.md` or accessible to your AI tool.
|
||||||
|
- If the `greyhaven` MCP server is available, use its tools:
|
||||||
|
- `list_components()` to find the right component for a UI need
|
||||||
|
- `get_component(name)` to get exact props, variants, and usage examples
|
||||||
|
- `validate_colors(code)` to check code for off-brand colors
|
||||||
|
- `suggest_component(description)` to get recommendations
|
||||||
|
- Import components from `components/ui/` (or `@/components/ui/` with path alias)
|
||||||
|
- Never use raw hex colors -- use semantic Tailwind classes (`bg-primary`, `text-foreground`, `border-border`, etc.)
|
||||||
|
- Use `font-sans` (Aspekta) for UI elements: buttons, nav, labels, forms
|
||||||
|
- Use `font-serif` (Source Serif) for content: headings, body text
|
||||||
|
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
|
||||||
|
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
|
||||||
|
- Dark mode is toggled via the `.dark` class -- use semantic tokens that adapt automatically
|
||||||
|
|
||||||
|
## Component Summary
|
||||||
|
|
||||||
|
38 components across 8 categories: primitives (11), layout (4), overlay (5), navigation (3), data (4), feedback (4), form (1), composition (6).
|
||||||
|
|
||||||
|
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP `get_component(name)` tool.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
- **CVA variants**: Components use `class-variance-authority` for variant props
|
||||||
|
- **Slot composition**: Components use `data-slot="name"` attributes
|
||||||
|
- **Class merging**: Always use `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
||||||
|
- **Focus rings**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
|
||||||
|
- **Disabled**: `disabled:pointer-events-none disabled:opacity-50`
|
||||||
|
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
|
||||||
|
- **Section rhythm**: `py-16` between major sections
|
||||||
|
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
|
||||||
|
|
||||||
|
## Font Setup
|
||||||
|
|
||||||
|
If fonts aren't loaded yet, add to your global CSS:
|
||||||
|
```css
|
||||||
|
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
|
||||||
|
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
|
||||||
|
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
|
||||||
|
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
}
|
}
|
||||||
|
handle /v2* {
|
||||||
|
reverse_proxy ui:80
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
|
# Reflector v2 UI — Vite SPA served at /v2 behind Caddy.
|
||||||
|
# Build-time env vars are baked into the bundle; pass VITE_OIDC_* via build args.
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: ./ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
|
||||||
|
VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID:-}
|
||||||
|
VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email}
|
||||||
|
image: monadicalsas/reflector-ui:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${BIND_HOST:-127.0.0.1}:3001:80"
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.2-alpine
|
image: redis:7.2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ services:
|
|||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: server
|
context: server
|
||||||
ports:
|
network_mode: host
|
||||||
- "1250:1250"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/:/app/
|
- ./server/:/app/
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
@@ -11,17 +10,12 @@ services:
|
|||||||
- ./server/.env
|
- ./server/.env
|
||||||
environment:
|
environment:
|
||||||
ENTRYPOINT: server
|
ENTRYPOINT: server
|
||||||
DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
|
DATABASE_URL: postgresql+asyncpg://reflector:reflector@localhost:5432/reflector
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: localhost
|
||||||
CELERY_BROKER_URL: redis://redis:6379/1
|
CELERY_BROKER_URL: redis://localhost:6379/1
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/1
|
CELERY_RESULT_BACKEND: redis://localhost:6379/1
|
||||||
HATCHET_CLIENT_SERVER_URL: http://hatchet:8888
|
HATCHET_CLIENT_SERVER_URL: http://localhost:8889
|
||||||
HATCHET_CLIENT_HOST_PORT: hatchet:7077
|
HATCHET_CLIENT_HOST_PORT: localhost:7078
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
|
|||||||
BIN
public/fonts/Aspekta-100.woff2
Normal file
BIN
public/fonts/Aspekta-100.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-1000.woff2
Normal file
BIN
public/fonts/Aspekta-1000.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-150.woff2
Normal file
BIN
public/fonts/Aspekta-150.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-200.woff2
Normal file
BIN
public/fonts/Aspekta-200.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-250.woff2
Normal file
BIN
public/fonts/Aspekta-250.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-300.woff2
Normal file
BIN
public/fonts/Aspekta-300.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-350.woff2
Normal file
BIN
public/fonts/Aspekta-350.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-400.woff2
Normal file
BIN
public/fonts/Aspekta-400.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-450.woff2
Normal file
BIN
public/fonts/Aspekta-450.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-50.woff2
Normal file
BIN
public/fonts/Aspekta-50.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-500.woff2
Normal file
BIN
public/fonts/Aspekta-500.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-550.woff2
Normal file
BIN
public/fonts/Aspekta-550.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-600.woff2
Normal file
BIN
public/fonts/Aspekta-600.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-650.woff2
Normal file
BIN
public/fonts/Aspekta-650.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-700.woff2
Normal file
BIN
public/fonts/Aspekta-700.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-750.woff2
Normal file
BIN
public/fonts/Aspekta-750.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-800.woff2
Normal file
BIN
public/fonts/Aspekta-800.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-850.woff2
Normal file
BIN
public/fonts/Aspekta-850.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-900.woff2
Normal file
BIN
public/fonts/Aspekta-900.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Aspekta-950.woff2
Normal file
BIN
public/fonts/Aspekta-950.woff2
Normal file
Binary file not shown.
161
public/fonts/font-face.css
Normal file
161
public/fonts/font-face.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 50;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-50.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-100.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 150;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-150.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-200.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 250;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-250.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-300.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 350;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-350.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 450;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-450.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 550;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-550.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 650;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-650.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-700.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 750;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-750.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-800.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 850;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-850.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-900.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 950;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-950.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Aspekta';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 1000;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('Aspekta-1000.woff2') format('woff2');
|
||||||
|
}
|
||||||
@@ -1494,6 +1494,9 @@ $CUSTOM_DOMAIN {
|
|||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
|
}
|
||||||
|
handle /v2* {
|
||||||
|
reverse_proxy ui:80
|
||||||
}${lk_proxy_block}${hatchet_proxy_block}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
@@ -1511,6 +1514,9 @@ $CUSTOM_DOMAIN {
|
|||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
|
}
|
||||||
|
handle /v2* {
|
||||||
|
reverse_proxy ui:80
|
||||||
}${lk_proxy_block}${hatchet_proxy_block}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
@@ -1532,6 +1538,9 @@ CADDYEOF
|
|||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
|
}
|
||||||
|
handle /v2* {
|
||||||
|
reverse_proxy ui:80
|
||||||
}${lk_proxy_block}${hatchet_proxy_block}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
@@ -1572,9 +1581,12 @@ step_services() {
|
|||||||
info "Building frontend image from source..."
|
info "Building frontend image from source..."
|
||||||
compose_cmd build web
|
compose_cmd build web
|
||||||
ok "Frontend image built"
|
ok "Frontend image built"
|
||||||
|
info "Building v2 UI image from source..."
|
||||||
|
compose_cmd build ui
|
||||||
|
ok "v2 UI image built"
|
||||||
else
|
else
|
||||||
info "Pulling latest backend and frontend images..."
|
info "Pulling latest backend and frontend images..."
|
||||||
compose_cmd pull server web || warn "Pull failed — using cached images"
|
compose_cmd pull server web ui || warn "Pull failed — using cached images"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Hatchet is always needed (all processing pipelines use it)
|
# Hatchet is always needed (all processing pipelines use it)
|
||||||
@@ -1737,6 +1749,24 @@ step_health() {
|
|||||||
warn "Frontend not responding. Check: docker compose logs web"
|
warn "Frontend not responding. Check: docker compose logs web"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# v2 UI
|
||||||
|
info "Waiting for v2 UI..."
|
||||||
|
local ui_ok=false
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://localhost:3001/v2/ > /dev/null 2>&1; then
|
||||||
|
ui_ok=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo -ne "\r Waiting for v2 UI... ($i/30)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
if [[ "$ui_ok" == "true" ]]; then
|
||||||
|
ok "v2 UI healthy"
|
||||||
|
else
|
||||||
|
warn "v2 UI not responding. Check: docker compose logs ui"
|
||||||
|
fi
|
||||||
|
|
||||||
# Caddy
|
# Caddy
|
||||||
if [[ "$USE_CADDY" == "true" ]]; then
|
if [[ "$USE_CADDY" == "true" ]]; then
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -1979,20 +2009,25 @@ EOF
|
|||||||
if [[ "$USE_CADDY" == "true" ]]; then
|
if [[ "$USE_CADDY" == "true" ]]; then
|
||||||
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
||||||
echo " App: https://$CUSTOM_DOMAIN"
|
echo " App: https://$CUSTOM_DOMAIN"
|
||||||
|
echo " App v2: https://$CUSTOM_DOMAIN/v2/"
|
||||||
echo " API: https://$CUSTOM_DOMAIN/v1/"
|
echo " API: https://$CUSTOM_DOMAIN/v1/"
|
||||||
elif [[ -n "$PRIMARY_IP" ]]; then
|
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||||
echo " App: https://$PRIMARY_IP (accept self-signed cert in browser)"
|
echo " App: https://$PRIMARY_IP (accept self-signed cert in browser)"
|
||||||
|
echo " App v2: https://$PRIMARY_IP/v2/"
|
||||||
echo " API: https://$PRIMARY_IP/v1/"
|
echo " API: https://$PRIMARY_IP/v1/"
|
||||||
echo " Local: https://localhost"
|
echo " Local: https://localhost"
|
||||||
else
|
else
|
||||||
echo " App: https://localhost (accept self-signed cert in browser)"
|
echo " App: https://localhost (accept self-signed cert in browser)"
|
||||||
|
echo " App v2: https://localhost/v2/"
|
||||||
echo " API: https://localhost/v1/"
|
echo " API: https://localhost/v1/"
|
||||||
fi
|
fi
|
||||||
elif [[ -n "$PRIMARY_IP" ]]; then
|
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||||
echo " App: http://$PRIMARY_IP:3000"
|
echo " App: http://$PRIMARY_IP:3000"
|
||||||
|
echo " App v2: http://$PRIMARY_IP:3001/v2/"
|
||||||
echo " API: http://$PRIMARY_IP:1250"
|
echo " API: http://$PRIMARY_IP:1250"
|
||||||
else
|
else
|
||||||
echo " App: http://localhost:3000"
|
echo " App: http://localhost:3000"
|
||||||
|
echo " App v2: http://localhost:3001/v2/"
|
||||||
echo " API: http://localhost:1250"
|
echo " API: http://localhost:1250"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ class SearchResult(BaseModel):
|
|||||||
total_match_count: NonNegativeInt = Field(
|
total_match_count: NonNegativeInt = Field(
|
||||||
default=0, description="Total number of matches found in the transcript"
|
default=0, description="Total number of matches found in the transcript"
|
||||||
)
|
)
|
||||||
|
speaker_count: NonNegativeInt = Field(
|
||||||
|
default=0, description="Number of distinct speakers in the transcript"
|
||||||
|
)
|
||||||
change_seq: int | None = None
|
change_seq: int | None = None
|
||||||
|
|
||||||
@field_serializer("created_at", when_used="json")
|
@field_serializer("created_at", when_used="json")
|
||||||
@@ -362,6 +365,7 @@ class SearchController:
|
|||||||
transcripts.c.change_seq,
|
transcripts.c.change_seq,
|
||||||
transcripts.c.webvtt,
|
transcripts.c.webvtt,
|
||||||
transcripts.c.long_summary,
|
transcripts.c.long_summary,
|
||||||
|
transcripts.c.participants,
|
||||||
sqlalchemy.case(
|
sqlalchemy.case(
|
||||||
(
|
(
|
||||||
transcripts.c.room_id.isnot(None) & rooms.c.id.is_(None),
|
transcripts.c.room_id.isnot(None) & rooms.c.id.is_(None),
|
||||||
@@ -458,6 +462,12 @@ class SearchController:
|
|||||||
long_summary_r: str | None = r_dict.pop("long_summary", None)
|
long_summary_r: str | None = r_dict.pop("long_summary", None)
|
||||||
long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r)
|
long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r)
|
||||||
room_name: str | None = r_dict.pop("room_name", None)
|
room_name: str | None = r_dict.pop("room_name", None)
|
||||||
|
participants_raw = r_dict.pop("participants", None) or []
|
||||||
|
speaker_count = (
|
||||||
|
len({p.get("speaker") for p in participants_raw if isinstance(p, dict)})
|
||||||
|
if isinstance(participants_raw, list)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
db_result = SearchResultDB.model_validate(r_dict)
|
db_result = SearchResultDB.model_validate(r_dict)
|
||||||
|
|
||||||
at_least_one_source = webvtt is not None or long_summary is not None
|
at_least_one_source = webvtt is not None or long_summary is not None
|
||||||
@@ -475,6 +485,7 @@ class SearchController:
|
|||||||
room_name=room_name,
|
room_name=room_name,
|
||||||
search_snippets=snippets,
|
search_snippets=snippets,
|
||||||
total_match_count=total_match_count,
|
total_match_count=total_match_count,
|
||||||
|
speaker_count=speaker_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -446,10 +446,19 @@ class TranscriptController:
|
|||||||
col for col in transcripts.c if col.name not in exclude_columns
|
col for col in transcripts.c if col.name not in exclude_columns
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Cheap speaker_count via JSON array length on the participants column
|
||||||
|
# (same column already stored on every transcript, no extra queries).
|
||||||
|
# COALESCE handles transcripts where participants is NULL.
|
||||||
|
speaker_count_col = sqlalchemy.func.coalesce(
|
||||||
|
sqlalchemy.func.json_array_length(transcripts.c.participants),
|
||||||
|
0,
|
||||||
|
).label("speaker_count")
|
||||||
|
|
||||||
query = query.with_only_columns(
|
query = query.with_only_columns(
|
||||||
transcript_columns
|
transcript_columns
|
||||||
+ [
|
+ [
|
||||||
rooms.c.name.label("room_name"),
|
rooms.c.name.label("room_name"),
|
||||||
|
speaker_count_col,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ class GetTranscriptMinimal(BaseModel):
|
|||||||
change_seq: int | None = None
|
change_seq: int | None = None
|
||||||
has_cloud_video: bool = False
|
has_cloud_video: bool = False
|
||||||
cloud_video_duration: int | None = None
|
cloud_video_duration: int | None = None
|
||||||
|
speaker_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TranscriptParticipantWithEmail(TranscriptParticipant):
|
class TranscriptParticipantWithEmail(TranscriptParticipant):
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import logging
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import reflector.auth as auth
|
import reflector.auth as auth
|
||||||
from reflector.zulip import get_zulip_streams, get_zulip_topics
|
from reflector.zulip import get_zulip_streams, get_zulip_topics
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -23,13 +26,18 @@ async def zulip_get_streams(
|
|||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
) -> list[Stream]:
|
) -> list[Stream]:
|
||||||
"""
|
"""
|
||||||
Get all Zulip streams.
|
Get all Zulip streams. Returns [] if the upstream Zulip API is unreachable
|
||||||
|
or the server credentials are invalid — the client treats Zulip as an
|
||||||
|
optional integration and renders gracefully without a hard error.
|
||||||
"""
|
"""
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=403, detail="Authentication required")
|
raise HTTPException(status_code=403, detail="Authentication required")
|
||||||
|
|
||||||
streams = await get_zulip_streams()
|
try:
|
||||||
return streams
|
return await get_zulip_streams()
|
||||||
|
except (httpx.HTTPStatusError, httpx.RequestError, Exception) as exc:
|
||||||
|
logger.warning("zulip get_streams failed, returning []: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/zulip/streams/{stream_id}/topics")
|
@router.get("/zulip/streams/{stream_id}/topics")
|
||||||
@@ -38,10 +46,14 @@ async def zulip_get_topics(
|
|||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
) -> list[Topic]:
|
) -> list[Topic]:
|
||||||
"""
|
"""
|
||||||
Get all topics for a specific Zulip stream.
|
Get all topics for a specific Zulip stream. Returns [] on upstream failure
|
||||||
|
for the same reason as /zulip/streams above.
|
||||||
"""
|
"""
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=403, detail="Authentication required")
|
raise HTTPException(status_code=403, detail="Authentication required")
|
||||||
|
|
||||||
topics = await get_zulip_topics(stream_id)
|
try:
|
||||||
return topics
|
return await get_zulip_topics(stream_id)
|
||||||
|
except (httpx.HTTPStatusError, httpx.RequestError, Exception) as exc:
|
||||||
|
logger.warning("zulip get_topics(%s) failed, returning []: %s", stream_id, exc)
|
||||||
|
return []
|
||||||
|
|||||||
6
ui/.dockerignore
Normal file
6
ui/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
10
ui/.env.example
Normal file
10
ui/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Base URL for the Reflector backend API.
|
||||||
|
# In dev, Vite proxies /v1 to this origin so keep it pointing at the local server.
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:1250
|
||||||
|
|
||||||
|
# OIDC (Authentik) — used when the backend runs in JWT / SSO mode.
|
||||||
|
# Leave blank in password-auth mode.
|
||||||
|
VITE_OIDC_AUTHORITY=
|
||||||
|
VITE_OIDC_CLIENT_ID=
|
||||||
|
# Scopes requested at login. Defaults to "openid profile email" when blank.
|
||||||
|
VITE_OIDC_SCOPE=openid profile email
|
||||||
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
23
ui/Dockerfile
Normal file
23
ui/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite bakes VITE_* env vars into the bundle at build time.
|
||||||
|
ARG VITE_OIDC_AUTHORITY=
|
||||||
|
ARG VITE_OIDC_CLIENT_ID=
|
||||||
|
ARG VITE_OIDC_SCOPE=openid profile email
|
||||||
|
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY \
|
||||||
|
VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \
|
||||||
|
VITE_OIDC_SCOPE=$VITE_OIDC_SCOPE
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html/v2
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
87
ui/README.md
Normal file
87
ui/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Reflector UI (v2)
|
||||||
|
|
||||||
|
Vite + React 19 + TypeScript SPA, served at `/v2` behind Caddy. Lives alongside the existing Next.js app in `../www` while the migration is in progress.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Vite** + **React 19** + **TypeScript**
|
||||||
|
- **Tailwind v4** with Greyhaven design tokens (`src/styles/greyhaven.css`)
|
||||||
|
- **React Router v7**, routes mounted under `/v2/*`
|
||||||
|
- **TanStack Query** + **openapi-fetch** with types generated from the backend OpenAPI spec
|
||||||
|
- **nuqs** for URL-backed page/search state on `/browse`
|
||||||
|
- **react-oidc-context** (OIDC Authorization Code + PKCE) for the JWT auth backend
|
||||||
|
- Password-form fallback for the `password` auth backend (`POST /v1/auth/login`)
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ui
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Point the dev server at your local backend (defaults to http://localhost:1250).
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit VITE_OIDC_AUTHORITY / VITE_OIDC_CLIENT_ID if your backend runs in JWT mode.
|
||||||
|
|
||||||
|
pnpm dev # http://localhost:3001/v2/
|
||||||
|
pnpm build # production bundle in dist/
|
||||||
|
pnpm typecheck # tsc --noEmit
|
||||||
|
pnpm openapi # regenerate src/api/schema.d.ts from the running backend
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm openapi` hits `http://127.0.0.1:1250/openapi.json` — start the backend first (`cd ../server && uv run -m reflector.app --reload`).
|
||||||
|
|
||||||
|
## Auth modes
|
||||||
|
|
||||||
|
The SPA auto-detects the backend's auth backend:
|
||||||
|
|
||||||
|
- **JWT (OIDC/SSO via Authentik):** set `VITE_OIDC_AUTHORITY` and `VITE_OIDC_CLIENT_ID`. The app does the Authorization Code + PKCE flow; Authentik hosts the login page. Register a **Public** OAuth client whose redirect URI is `https://<your-domain>/v2/auth/callback`. No client secret is baked into the bundle.
|
||||||
|
- **Password:** leave the OIDC env vars blank. The app shows an in-page email/password form that posts to `/v1/auth/login` and stores the returned JWT in `sessionStorage`.
|
||||||
|
- **None:** backend returns a fake user for every request; the SPA treats that as authenticated.
|
||||||
|
|
||||||
|
## Deployment (selfhosted)
|
||||||
|
|
||||||
|
`docker-compose.selfhosted.yml` defines a `ui` service that builds this directory and serves the static bundle from nginx on port 80. Caddy routes `/v2/*` to `ui:80` and leaves the root path on the existing `web` service.
|
||||||
|
|
||||||
|
Pass OIDC config as build args (Vite inlines `VITE_*` at build time):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_OIDC_AUTHORITY=https://auth.example/application/o/reflector/ \
|
||||||
|
VITE_OIDC_CLIENT_ID=reflector-ui \
|
||||||
|
docker compose -f docker-compose.selfhosted.yml build ui
|
||||||
|
docker compose -f docker-compose.selfhosted.yml up -d ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pages shipped in this pass
|
||||||
|
|
||||||
|
- `/` — Home / Create new transcript (single-form shipping variant)
|
||||||
|
- `/browse` — transcript list with FTS search, source/room/trash filters, pagination
|
||||||
|
- `/rooms` — rooms list, create, edit, delete
|
||||||
|
- `/welcome` — logged-out landing (OIDC mode)
|
||||||
|
- `/login` — password login form (password mode)
|
||||||
|
- `/auth/callback` — OIDC redirect target
|
||||||
|
|
||||||
|
Not yet ported:
|
||||||
|
- Transcript detail / playback
|
||||||
|
- Meeting / live join
|
||||||
|
- Settings, API keys
|
||||||
|
- Tags sidebar (backend model doesn't exist yet)
|
||||||
|
- Progress streaming over WebSocket
|
||||||
|
|
||||||
|
## Directory map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/ fetch client, generated OpenAPI types
|
||||||
|
auth/ AuthProvider, RequireAuth, OIDC config
|
||||||
|
components/
|
||||||
|
browse/ TranscriptRow, FilterBar, Pagination
|
||||||
|
home/ LanguagePair, RoomPicker
|
||||||
|
icons.tsx lucide-react wrapper (compat with prototype I.* shape)
|
||||||
|
layout/ AppShell, AppSidebar, TopBar
|
||||||
|
rooms/ RoomsTable, RoomFormDialog, DeleteRoomDialog
|
||||||
|
ui/ primitives (Button, StatusDot, StatusBadge, SidebarItem, …)
|
||||||
|
hooks/ useRooms, useTranscripts
|
||||||
|
lib/ utils, format helpers, types
|
||||||
|
pages/ HomePage, BrowsePage, RoomsPage, LoggedOut, LoginForm, AuthCallback
|
||||||
|
styles/ greyhaven.css, reflector-forms.css, index.css (Tailwind entry)
|
||||||
|
```
|
||||||
23
ui/eslint.config.js
Normal file
23
ui/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
22
ui/index.html
Normal file
22
ui/index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="./favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="./favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Reflector</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400&display=swap"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
ui/nginx.conf
Normal file
27
ui/nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Without the trailing slash, redirect so relative asset paths resolve.
|
||||||
|
location = /v2 {
|
||||||
|
return 301 /v2/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# React Router SPA under /v2 — fall back to index.html for client routes.
|
||||||
|
location /v2/ {
|
||||||
|
try_files $uri $uri/ /v2/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root convenience redirect to the SPA entry.
|
||||||
|
location = / {
|
||||||
|
return 302 /v2/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long-cache hashed assets emitted by Vite.
|
||||||
|
location ~* /v2/assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ui/package.json
Normal file
62
ui/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"openapi": "openapi-typescript http://localhost:1250/openapi.json -o ./src/api/schema.d.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@tanstack/react-query": "^5.99.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"nuqs": "^2.8.9",
|
||||||
|
"oidc-client-ts": "^3.5.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"openapi-react-query": "^0.5.4",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hook-form": "^7.73.1",
|
||||||
|
"react-oidc-context": "^3.3.1",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
|
"vite": "^8.0.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
3536
ui/pnpm-lock.yaml
generated
Normal file
3536
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
ui/public/apple-touch-icon.png
Normal file
BIN
ui/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
ui/public/favicon-16x16.png
Normal file
BIN
ui/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 678 B |
BIN
ui/public/favicon-32x32.png
Normal file
BIN
ui/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
24
ui/public/icons.svg
Normal file
24
ui/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
47
ui/scripts/debug-root.sh
Executable file
47
ui/scripts/debug-root.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Diagnoses why the raw domain (https://reflector.local/) isn't loading.
|
||||||
|
# Usage: ./ui/scripts/debug-root.sh [host]
|
||||||
|
set +e
|
||||||
|
HOST="${1:-reflector.local}"
|
||||||
|
COMPOSE="docker compose -f docker-compose.selfhosted.yml"
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 1. Container status (web + caddy)"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE ps web caddy 2>&1 | head -10
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 2. HTTPS probe to https://$HOST/"
|
||||||
|
echo "============================================================"
|
||||||
|
curl -skv "https://$HOST/" 2>&1 | head -60
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 3. Body snippet"
|
||||||
|
echo "============================================================"
|
||||||
|
curl -sk "https://$HOST/" 2>&1 | head -30
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 4. Direct web:3000 probe from inside caddy"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE exec -T caddy wget -qO- --server-response http://web:3000/ 2>&1 | head -30
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 5. NextAuth URL / relevant web env (from inside web)"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE exec -T web printenv 2>&1 | grep -E 'NEXTAUTH|NEXT_PUBLIC|SERVER_API_URL' | head -10
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 6. web container logs (last 40 lines)"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE logs --tail=40 web 2>&1 | tail -40
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 7. caddy recent errors to the web upstream (last 10)"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE logs --tail=200 caddy 2>&1 | grep -Ei 'error|web:3000|dial tcp' | tail -10
|
||||||
63
ui/scripts/debug-v2.sh
Executable file
63
ui/scripts/debug-v2.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Diagnoses why reflector.local/v2/ isn't serving the SPA.
|
||||||
|
# Usage: ./ui/scripts/debug-v2.sh [host] (default host: reflector.local)
|
||||||
|
set +e
|
||||||
|
HOST="${1:-reflector.local}"
|
||||||
|
COMPOSE="docker compose -f docker-compose.selfhosted.yml"
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 1. Container status"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE ps ui caddy web 2>&1 | head -20
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 2. Live Caddyfile inside the caddy container"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE exec -T caddy cat /etc/caddy/Caddyfile 2>&1 | sed -n '/handle \/v2\|handle {/{p;n;p;n;p;}' | head -20
|
||||||
|
echo "--- full handle blocks (first 40 lines) ---"
|
||||||
|
$COMPOSE exec -T caddy cat /etc/caddy/Caddyfile 2>&1 | grep -nE 'handle|reverse_proxy|tls' | head -40
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 3. nginx config inside the ui container"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE exec -T ui cat /etc/nginx/conf.d/default.conf 2>&1
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 4. dist contents inside the ui container"
|
||||||
|
echo "============================================================"
|
||||||
|
$COMPOSE exec -T ui ls -la /usr/share/nginx/html/v2/ 2>&1 | head -20
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 5. Direct nginx probe (bypass Caddy) — container -> container"
|
||||||
|
echo "============================================================"
|
||||||
|
echo "--- GET http://ui/v2/ from inside caddy ---"
|
||||||
|
$COMPOSE exec -T caddy wget -qO- --server-response http://ui/v2/ 2>&1 | head -40
|
||||||
|
echo
|
||||||
|
echo "--- GET http://ui/v2 (no slash) from inside caddy ---"
|
||||||
|
$COMPOSE exec -T caddy wget -qO- --server-response http://ui/v2 2>&1 | head -20
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 6. Caddy probe from host"
|
||||||
|
echo "============================================================"
|
||||||
|
echo "--- GET https://$HOST/v2/ ---"
|
||||||
|
curl -sk -o /dev/null -D - "https://$HOST/v2/" 2>&1 | head -20
|
||||||
|
echo
|
||||||
|
echo "--- GET https://$HOST/v2 (no slash) ---"
|
||||||
|
curl -sk -o /dev/null -D - "https://$HOST/v2" 2>&1 | head -20
|
||||||
|
echo
|
||||||
|
echo "--- body of https://$HOST/v2/ (first 30 lines) ---"
|
||||||
|
curl -sk "https://$HOST/v2/" 2>&1 | head -30
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo " 7. Recent ui + caddy logs"
|
||||||
|
echo "============================================================"
|
||||||
|
echo "--- ui (last 30 lines) ---"
|
||||||
|
$COMPOSE logs --tail=30 ui 2>&1 | tail -30
|
||||||
|
echo "--- caddy (last 30 lines) ---"
|
||||||
|
$COMPOSE logs --tail=30 caddy 2>&1 | tail -30
|
||||||
74
ui/src/App.tsx
Normal file
74
ui/src/App.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { AuthProvider } from '@/auth/AuthProvider'
|
||||||
|
import { RequireAuth } from '@/auth/RequireAuth'
|
||||||
|
import { BrowsePage } from '@/pages/BrowsePage'
|
||||||
|
import { RoomsPage } from '@/pages/RoomsPage'
|
||||||
|
import { TranscriptPage } from '@/pages/TranscriptPage'
|
||||||
|
import { LoggedOutPage } from '@/pages/LoggedOut'
|
||||||
|
import { LoginForm } from '@/pages/LoginForm'
|
||||||
|
import { AuthCallbackPage } from '@/pages/AuthCallback'
|
||||||
|
|
||||||
|
function TranscriptRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={`/transcripts/${id}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter basename="/v2">
|
||||||
|
<NuqsAdapter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginForm />} />
|
||||||
|
<Route path="/welcome" element={<LoggedOutPage />} />
|
||||||
|
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||||
|
<Route path="/auth/silent-renew" element={<AuthCallbackPage />} />
|
||||||
|
<Route path="/" element={<Navigate to="/browse" replace />} />
|
||||||
|
<Route
|
||||||
|
path="/browse"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BrowsePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/rooms"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RoomsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/transcripts/:id"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<TranscriptPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/transcript/:id" element={<TranscriptRedirect />} />
|
||||||
|
<Route path="*" element={<Navigate to="/browse" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: 'var(--card)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
ui/src/api/client.ts
Normal file
32
ui/src/api/client.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import createClient, { type Middleware } from 'openapi-fetch'
|
||||||
|
import createQueryClient from 'openapi-react-query'
|
||||||
|
import type { paths } from './schema'
|
||||||
|
|
||||||
|
export const PASSWORD_TOKEN_KEY = 'reflector.password_token'
|
||||||
|
|
||||||
|
let oidcAccessTokenGetter: (() => string | null) | null = null
|
||||||
|
export function setOidcAccessTokenGetter(getter: (() => string | null) | null) {
|
||||||
|
oidcAccessTokenGetter = getter
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPasswordToken(token: string | null) {
|
||||||
|
if (token) sessionStorage.setItem(PASSWORD_TOKEN_KEY, token)
|
||||||
|
else sessionStorage.removeItem(PASSWORD_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasswordToken() {
|
||||||
|
return sessionStorage.getItem(PASSWORD_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const authMiddleware: Middleware = {
|
||||||
|
async onRequest({ request }) {
|
||||||
|
const token = oidcAccessTokenGetter?.() ?? getPasswordToken()
|
||||||
|
if (token) request.headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
return request
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = createClient<paths>({ baseUrl: '/' })
|
||||||
|
apiClient.use(authMiddleware)
|
||||||
|
|
||||||
|
export const $api = createQueryClient(apiClient)
|
||||||
15
ui/src/api/queryClient.ts
Normal file
15
ui/src/api/queryClient.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 15_000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
const status = (error as { status?: number } | null)?.status
|
||||||
|
if (status === 401 || status === 403 || status === 404) return false
|
||||||
|
return failureCount < 2
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
4556
ui/src/api/schema.d.ts
vendored
Normal file
4556
ui/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
28
ui/src/auth/AuthContext.tsx
Normal file
28
ui/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export type AuthMode = 'oidc' | 'password'
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
email?: string | null
|
||||||
|
name?: string | null
|
||||||
|
sub?: string | null
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export type AuthContextValue = {
|
||||||
|
mode: AuthMode
|
||||||
|
loading: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
user: AuthUser
|
||||||
|
error: Error | null
|
||||||
|
loginWithOidc: () => void
|
||||||
|
loginWithPassword: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const value = useContext(AuthContext)
|
||||||
|
if (!value) throw new Error('useAuth must be used inside AuthProvider')
|
||||||
|
return value
|
||||||
|
}
|
||||||
129
ui/src/auth/AuthProvider.tsx
Normal file
129
ui/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
AuthProvider as OidcAuthProvider,
|
||||||
|
useAuth as useOidcAuth,
|
||||||
|
} from 'react-oidc-context'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { apiClient, getPasswordToken, setPasswordToken, setOidcAccessTokenGetter } from '@/api/client'
|
||||||
|
import { AuthContext, type AuthContextValue, type AuthUser } from './AuthContext'
|
||||||
|
import { buildOidcConfig, oidcEnabled } from './oidcConfig'
|
||||||
|
|
||||||
|
function useMeQuery(tokenKey: string | null | undefined) {
|
||||||
|
return useQuery<AuthUser>({
|
||||||
|
queryKey: ['auth', 'me', tokenKey ?? 'anon'],
|
||||||
|
enabled: !!tokenKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error, response } = await apiClient.GET('/v1/me')
|
||||||
|
if (error || !response.ok) {
|
||||||
|
if (response.status === 401) return null
|
||||||
|
throw Object.assign(new Error('me request failed'), { status: response.status })
|
||||||
|
}
|
||||||
|
return (data ?? null) as AuthUser
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordAuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [token, setToken] = useState<string | null>(() => getPasswordToken())
|
||||||
|
const meQuery = useMeQuery(token)
|
||||||
|
|
||||||
|
const loginWithPassword = useCallback(
|
||||||
|
async (email: string, password: string) => {
|
||||||
|
const res = await fetch('/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res
|
||||||
|
.json()
|
||||||
|
.then((j: { detail?: string }) => j?.detail)
|
||||||
|
.catch(() => null)
|
||||||
|
throw new Error(detail ?? 'Invalid credentials')
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { access_token: string }
|
||||||
|
setPasswordToken(json.access_token)
|
||||||
|
setToken(json.access_token)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
setPasswordToken(null)
|
||||||
|
setToken(null)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
const loginWithOidc = useCallback(() => {
|
||||||
|
console.warn('OIDC login not configured; use loginWithPassword')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
mode: 'password',
|
||||||
|
loading: meQuery.isLoading,
|
||||||
|
authenticated: !!token && meQuery.data != null,
|
||||||
|
user: meQuery.data ?? null,
|
||||||
|
error: (meQuery.error as Error | null) ?? null,
|
||||||
|
loginWithOidc,
|
||||||
|
loginWithPassword,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[token, meQuery.isLoading, meQuery.data, meQuery.error, loginWithOidc, loginWithPassword, logout],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
function OidcAuthBridge({ children }: { children: ReactNode }) {
|
||||||
|
const oidc = useOidcAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const accessToken = oidc.user?.access_token ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOidcAccessTokenGetter(() => accessToken)
|
||||||
|
return () => setOidcAccessTokenGetter(null)
|
||||||
|
}, [accessToken])
|
||||||
|
|
||||||
|
const meQuery = useMeQuery(accessToken)
|
||||||
|
|
||||||
|
const loginWithOidc = useCallback(() => oidc.signinRedirect(), [oidc])
|
||||||
|
const loginWithPassword = useCallback(async () => {
|
||||||
|
throw new Error('Password login is disabled in OIDC mode')
|
||||||
|
}, [])
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await oidc.signoutRedirect().catch(() => oidc.removeUser())
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
|
}, [oidc, queryClient])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
mode: 'oidc',
|
||||||
|
loading: oidc.isLoading || meQuery.isLoading,
|
||||||
|
authenticated: !!accessToken && meQuery.data != null,
|
||||||
|
user: meQuery.data ?? null,
|
||||||
|
error: (oidc.error ?? (meQuery.error as Error | null)) ?? null,
|
||||||
|
loginWithOidc,
|
||||||
|
loginWithPassword,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[oidc.isLoading, oidc.error, accessToken, meQuery.isLoading, meQuery.data, meQuery.error, loginWithOidc, loginWithPassword, logout],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const config = buildOidcConfig()
|
||||||
|
if (!config || !oidcEnabled) {
|
||||||
|
return <PasswordAuthProvider>{children}</PasswordAuthProvider>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<OidcAuthProvider {...config}>
|
||||||
|
<OidcAuthBridge>{children}</OidcAuthBridge>
|
||||||
|
</OidcAuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
ui/src/auth/RequireAuth.tsx
Normal file
30
ui/src/auth/RequireAuth.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { loading, authenticated } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return <Navigate to="/welcome" state={{ from: location.pathname }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
27
ui/src/auth/oidcConfig.ts
Normal file
27
ui/src/auth/oidcConfig.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AuthProviderProps } from 'react-oidc-context'
|
||||||
|
import { WebStorageStateStore } from 'oidc-client-ts'
|
||||||
|
import { env, oidcEnabled } from '@/lib/env'
|
||||||
|
|
||||||
|
export { oidcEnabled }
|
||||||
|
|
||||||
|
export function buildOidcConfig(): AuthProviderProps | null {
|
||||||
|
if (!oidcEnabled) return null
|
||||||
|
const redirectUri = `${window.location.origin}/v2/auth/callback`
|
||||||
|
const silentRedirectUri = `${window.location.origin}/v2/auth/silent-renew`
|
||||||
|
const postLogoutRedirectUri = `${window.location.origin}/v2/`
|
||||||
|
return {
|
||||||
|
authority: env.oidcAuthority,
|
||||||
|
client_id: env.oidcClientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
silent_redirect_uri: silentRedirectUri,
|
||||||
|
post_logout_redirect_uri: postLogoutRedirectUri,
|
||||||
|
scope: env.oidcScope,
|
||||||
|
response_type: 'code',
|
||||||
|
loadUserInfo: true,
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
||||||
|
onSigninCallback: () => {
|
||||||
|
window.history.replaceState({}, document.title, '/v2/')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
130
ui/src/components/browse/ConfirmDialog.tsx
Normal file
130
ui/src/components/browse/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string
|
||||||
|
message: ReactNode
|
||||||
|
confirmLabel: string
|
||||||
|
cancelLabel?: string
|
||||||
|
danger?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
danger,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !loading) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', k)
|
||||||
|
return () => document.removeEventListener('keydown', k)
|
||||||
|
}, [onClose, loading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={() => !loading && onClose()} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ width: 'min(440px, calc(100vw - 32px))' }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: danger
|
||||||
|
? 'color-mix(in srgb, var(--destructive) 12%, transparent)'
|
||||||
|
: 'var(--muted)',
|
||||||
|
color: danger ? 'var(--destructive)' : 'var(--fg-muted)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Trash(18)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '6px 0 0',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="md"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ color: 'var(--fg)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={danger ? 'danger' : 'primary'}
|
||||||
|
size="md"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
style={
|
||||||
|
danger
|
||||||
|
? {
|
||||||
|
background: 'var(--destructive)',
|
||||||
|
color: 'var(--destructive-fg)',
|
||||||
|
borderColor: 'var(--destructive)',
|
||||||
|
boxShadow: 'var(--shadow-xs)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? 'Working…' : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
ui/src/components/browse/FilterBar.tsx
Normal file
127
ui/src/components/browse/FilterBar.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
import type { RoomRowData, SidebarFilter, TagRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
type SortKey = 'newest' | 'oldest' | 'longest'
|
||||||
|
|
||||||
|
type FilterBarProps = {
|
||||||
|
filter: SidebarFilter
|
||||||
|
rooms: RoomRowData[]
|
||||||
|
tags: TagRowData[]
|
||||||
|
total: number
|
||||||
|
sort: SortKey
|
||||||
|
onSort: (s: SortKey) => void
|
||||||
|
query: string
|
||||||
|
onSearch: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filter,
|
||||||
|
rooms,
|
||||||
|
tags,
|
||||||
|
total,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
query,
|
||||||
|
onSearch,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
let label = 'All transcripts'
|
||||||
|
if (filter.kind === 'source' && filter.value === 'live') label = 'Live transcripts'
|
||||||
|
if (filter.kind === 'source' && filter.value === 'file') label = 'Uploaded files'
|
||||||
|
if (filter.kind === 'room') {
|
||||||
|
const r = rooms.find((x) => x.id === filter.value)
|
||||||
|
label = r ? `Room · ${r.name}` : 'Room'
|
||||||
|
}
|
||||||
|
if (filter.kind === 'tag') {
|
||||||
|
const t = tags.find((x) => x.id === filter.value)
|
||||||
|
label = t ? `Tagged · #${t.name}` : 'Tag'
|
||||||
|
}
|
||||||
|
if (filter.kind === 'trash') label = 'Trash'
|
||||||
|
if (filter.kind === 'recent') label = 'Recent (last 7 days)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--card)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--fg)', fontWeight: 600 }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total} {total === 1 ? 'result' : 'results'}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: 12,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
height: 30,
|
||||||
|
padding: '0 10px',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
width: 320,
|
||||||
|
maxWidth: '40%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', display: 'inline-flex' }}>{I.Search(13)}</span>
|
||||||
|
<input
|
||||||
|
value={query || ''}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
placeholder="Search transcripts, speakers, rooms…"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="rf-kbd">⌘K</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
sort
|
||||||
|
</span>
|
||||||
|
{(['newest', 'oldest', 'longest'] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onSort(s)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: sort === s ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
fontWeight: sort === s ? 600 : 500,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: sort === s ? 'var(--muted)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
ui/src/components/browse/Pagination.tsx
Normal file
74
ui/src/components/browse/Pagination.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
page: number
|
||||||
|
total: number
|
||||||
|
pageSize: number
|
||||||
|
onPage: (n: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, total, pageSize, onPage }: Props) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
const start = (page - 1) * pageSize + 1
|
||||||
|
const end = Math.min(total, page * pageSize)
|
||||||
|
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
background: 'var(--card)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{start}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => onPage(page - 1)}
|
||||||
|
>
|
||||||
|
{I.ChevronLeft(14)}
|
||||||
|
</Button>
|
||||||
|
{pages.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => onPage(n)}
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: n === page ? 'var(--primary)' : 'var(--border)',
|
||||||
|
background: n === page ? 'var(--primary)' : 'var(--card)',
|
||||||
|
color: n === page ? 'var(--primary-fg)' : 'var(--fg)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => onPage(page + 1)}
|
||||||
|
>
|
||||||
|
{I.ChevronRight(14)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
308
ui/src/components/browse/TranscriptRow.tsx
Normal file
308
ui/src/components/browse/TranscriptRow.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { RowMenuTrigger } from '@/components/ui/primitives'
|
||||||
|
import { fmtDate, fmtDur } from '@/lib/format'
|
||||||
|
import type { TranscriptRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TranscriptRowData
|
||||||
|
active?: boolean
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
query?: string
|
||||||
|
density?: 'compact' | 'comfortable'
|
||||||
|
onDelete?: (t: TranscriptRowData) => void
|
||||||
|
onReprocess?: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiStatus = 'recording' | 'ended' | 'processing' | 'uploaded' | 'error' | 'idle'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, ApiStatus> = {
|
||||||
|
live: 'recording',
|
||||||
|
ended: 'ended',
|
||||||
|
processing: 'processing',
|
||||||
|
uploading: 'uploaded',
|
||||||
|
failed: 'error',
|
||||||
|
idle: 'idle',
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIconFor(apiStatus: ApiStatus): { node: ReactNode; color: string } {
|
||||||
|
switch (apiStatus) {
|
||||||
|
case 'recording':
|
||||||
|
return { node: I.Radio(14), color: 'var(--status-live)' }
|
||||||
|
case 'processing':
|
||||||
|
return {
|
||||||
|
node: (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 9999,
|
||||||
|
display: 'inline-block',
|
||||||
|
border: '2px solid color-mix(in oklch, var(--status-processing) 25%, transparent)',
|
||||||
|
borderTopColor: 'var(--status-processing)',
|
||||||
|
animation: 'rfSpin 0.9s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
color: 'var(--status-processing)',
|
||||||
|
}
|
||||||
|
case 'uploaded':
|
||||||
|
return { node: I.Clock(14), color: 'var(--fg-muted)' }
|
||||||
|
case 'error':
|
||||||
|
return { node: I.AlertTriangle(14), color: 'var(--destructive)' }
|
||||||
|
case 'ended':
|
||||||
|
return { node: I.CheckCircle(14), color: 'var(--status-ok)' }
|
||||||
|
default:
|
||||||
|
return { node: I.Clock(14), color: 'var(--fg-muted)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowMenu(
|
||||||
|
t: TranscriptRowData,
|
||||||
|
onDelete?: (t: TranscriptRowData) => void,
|
||||||
|
onReprocess?: (id: string) => void,
|
||||||
|
) {
|
||||||
|
const apiStatus = STATUS_MAP[t.status] ?? 'idle'
|
||||||
|
const canReprocess = apiStatus === 'ended' || apiStatus === 'error'
|
||||||
|
return [
|
||||||
|
{ label: 'Open', icon: I.ExternalLink(14) },
|
||||||
|
{ label: 'Rename', icon: I.Edit(14) },
|
||||||
|
{ separator: true as const },
|
||||||
|
{
|
||||||
|
label: 'Reprocess',
|
||||||
|
icon: I.Refresh(14),
|
||||||
|
disabled: !canReprocess,
|
||||||
|
onClick: () => onReprocess?.(t.id),
|
||||||
|
},
|
||||||
|
{ separator: true as const },
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: I.Trash(14),
|
||||||
|
danger: true,
|
||||||
|
onClick: () => onDelete?.(t),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Highlight({ text, query }: { text: string; query?: string }) {
|
||||||
|
if (!query || !text) return <>{text}</>
|
||||||
|
const i = text.toLowerCase().indexOf(query.toLowerCase())
|
||||||
|
if (i < 0) return <>{text}</>
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, i)}
|
||||||
|
<mark
|
||||||
|
style={{
|
||||||
|
background: 'var(--reflector-accent-tint2)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
padding: '0 2px',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text.slice(i, i + query.length)}
|
||||||
|
</mark>
|
||||||
|
{text.slice(i + query.length)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptRow({
|
||||||
|
t,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
query,
|
||||||
|
density = 'comfortable',
|
||||||
|
onDelete,
|
||||||
|
onReprocess,
|
||||||
|
}: Props) {
|
||||||
|
const compact = density === 'compact'
|
||||||
|
const vpad = compact ? 10 : 14
|
||||||
|
const apiStatus = STATUS_MAP[t.status] ?? 'idle'
|
||||||
|
const statusIcon = statusIconFor(apiStatus)
|
||||||
|
const sourceLabel = t.source === 'room' ? t.room || 'room' : t.source
|
||||||
|
const isError = apiStatus === 'error'
|
||||||
|
const errorMsg = isError ? t.error_message || t.error || 'Processing failed — reason unavailable' : null
|
||||||
|
const snippet = query && t.snippet ? t.snippet : null
|
||||||
|
const matchCount = query && t.snippet ? 1 : 0
|
||||||
|
|
||||||
|
const [srcLang, tgtLang] = (t.lang || '').includes('→')
|
||||||
|
? (t.lang as string).split('→').map((s) => s.trim())
|
||||||
|
: [t.lang, null]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rf-row"
|
||||||
|
data-active={active ? 'true' : undefined}
|
||||||
|
onClick={() => onSelect?.(t.id)}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr auto auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: 14,
|
||||||
|
padding: `${vpad}px 20px`,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
width: 2,
|
||||||
|
background: 'var(--primary)',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span style={{ color: statusIcon.color, display: 'inline-flex' }}>{statusIcon.node}</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: compact ? 2 : 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: compact ? 14 : 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
letterSpacing: '-0.005em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Highlight text={t.title || 'Unnamed transcript'} query={query} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
rowGap: 2,
|
||||||
|
columnGap: 0,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{sourceLabel}</span>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{fmtDate(t.date)}</span>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{fmtDur(t.duration)}</span>
|
||||||
|
|
||||||
|
{t.speakers > 0 && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{I.Users(11)} {t.speakers} {t.speakers === 1 ? 'speaker' : 'speakers'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{srcLang && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
color: tgtLang ? 'var(--primary)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Globe(11)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{srcLang}
|
||||||
|
{tgtLang && <> → {tgtLang}</>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: 11.5,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
background: 'color-mix(in oklch, var(--destructive) 8%, transparent)',
|
||||||
|
border: '1px solid color-mix(in oklch, var(--destructive) 20%, transparent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginTop: 1, flexShrink: 0 }}>{I.AlertTriangle(11)}</span>
|
||||||
|
<span style={{ minWidth: 0 }}>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snippet && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
background: 'var(--muted)',
|
||||||
|
borderLeft: '2px solid var(--primary)',
|
||||||
|
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“<Highlight text={snippet} query={query} />”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{matchCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '1px 8px',
|
||||||
|
height: 18,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--primary)',
|
||||||
|
background: 'var(--reflector-accent-tint)',
|
||||||
|
border: '1px solid var(--reflector-accent-tint2)',
|
||||||
|
borderRadius: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchCount} match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<RowMenuTrigger items={buildRowMenu(t, onDelete, onReprocess)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
ui/src/components/browse/TrashRow.tsx
Normal file
101
ui/src/components/browse/TrashRow.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { RowMenuTrigger } from '@/components/ui/primitives'
|
||||||
|
import { fmtDate, fmtDur } from '@/lib/format'
|
||||||
|
import type { TranscriptRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TranscriptRowData
|
||||||
|
onRestore?: (id: string) => void
|
||||||
|
onDestroy?: (t: TranscriptRowData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashRow({ t, onRestore, onDestroy }: Props) {
|
||||||
|
const sourceLabel = t.source === 'room' ? t.room || 'room' : t.source
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rf-row"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: 14,
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
cursor: 'default',
|
||||||
|
position: 'relative',
|
||||||
|
opacity: 0.78,
|
||||||
|
background:
|
||||||
|
'repeating-linear-gradient(45deg, transparent 0 12px, color-mix(in oklch, var(--muted) 40%, transparent) 12px 13px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', display: 'inline-flex' }}>{I.Trash(14)}</span>
|
||||||
|
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
letterSpacing: '-0.005em',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
textDecorationColor: 'color-mix(in oklch, var(--fg-muted) 50%, transparent)',
|
||||||
|
textDecorationThickness: '1px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.title || 'Unnamed transcript'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
rowGap: 2,
|
||||||
|
columnGap: 0,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{sourceLabel}</span>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{fmtDate(t.date)}</span>
|
||||||
|
{t.duration > 0 && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{fmtDur(t.duration)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{I.Trash(11)} Deleted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RowMenuTrigger
|
||||||
|
label="Trash options"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Restore',
|
||||||
|
icon: I.Undo(14),
|
||||||
|
onClick: () => onRestore?.(t.id),
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: 'Destroy permanently',
|
||||||
|
icon: I.Trash(14),
|
||||||
|
danger: true,
|
||||||
|
onClick: () => onDestroy?.(t),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
ui/src/components/home/LanguagePair.tsx
Normal file
97
ui/src/components/home/LanguagePair.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { REFLECTOR_LANGS } from '@/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sourceLang: string
|
||||||
|
setSourceLang: (v: string) => void
|
||||||
|
targetLang: string
|
||||||
|
setTargetLang: (v: string) => void
|
||||||
|
horizontal?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguagePair({
|
||||||
|
sourceLang,
|
||||||
|
setSourceLang,
|
||||||
|
targetLang,
|
||||||
|
setTargetLang,
|
||||||
|
horizontal,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: horizontal ? '1fr auto 1fr' : '1fr',
|
||||||
|
gap: horizontal ? 8 : 14,
|
||||||
|
alignItems: 'end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-source-lang">
|
||||||
|
{I.Mic(13)} Spoken language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-source-lang"
|
||||||
|
className="rf-select"
|
||||||
|
value={sourceLang}
|
||||||
|
onChange={(e) => setSourceLang(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
{REFLECTOR_LANGS.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="rf-hint">Detected from the audio if set to Auto.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{horizontal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const a = sourceLang
|
||||||
|
setSourceLang(targetLang)
|
||||||
|
setTargetLang(a)
|
||||||
|
}}
|
||||||
|
title="Swap languages"
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
marginBottom: 18,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Swap(16)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-target-lang">
|
||||||
|
{I.Globe(13)} Translate to
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-target-lang"
|
||||||
|
className="rf-select"
|
||||||
|
value={targetLang}
|
||||||
|
onChange={(e) => setTargetLang(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<option value="">— None (same as spoken) —</option>
|
||||||
|
{REFLECTOR_LANGS.filter((l) => l.code !== 'auto').map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="rf-hint">Leave blank to skip translation.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
ui/src/components/home/RoomPicker.tsx
Normal file
33
ui/src/components/home/RoomPicker.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
import type { RoomRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
roomId: string
|
||||||
|
setRoomId: (v: string) => void
|
||||||
|
rooms: RoomRowData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomPicker({ roomId, setRoomId, rooms }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-room">
|
||||||
|
{I.Folder(13)} Attach to room{' '}
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontWeight: 400 }}>— optional</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-room"
|
||||||
|
className="rf-select"
|
||||||
|
value={roomId}
|
||||||
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
ui/src/components/icons.tsx
Normal file
162
ui/src/components/icons.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpDown,
|
||||||
|
Bell,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Cloud,
|
||||||
|
Copy,
|
||||||
|
DoorClosed,
|
||||||
|
DoorOpen,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
ExternalLink,
|
||||||
|
File,
|
||||||
|
FileAudio,
|
||||||
|
Filter,
|
||||||
|
Folder,
|
||||||
|
Globe,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
Info,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader,
|
||||||
|
Lock,
|
||||||
|
Mic,
|
||||||
|
Moon,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
Radio,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Undo,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Waves,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
Bell,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Cloud,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
ExternalLink,
|
||||||
|
File,
|
||||||
|
FileAudio,
|
||||||
|
Filter,
|
||||||
|
Folder,
|
||||||
|
Globe,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
Info,
|
||||||
|
Loader,
|
||||||
|
Lock,
|
||||||
|
Mic,
|
||||||
|
Moon,
|
||||||
|
Plus,
|
||||||
|
Radio,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
Tag,
|
||||||
|
Undo,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Waves,
|
||||||
|
X,
|
||||||
|
}
|
||||||
|
export { DoorClosed as Door }
|
||||||
|
export { DoorOpen }
|
||||||
|
export { Trash2 as Trash }
|
||||||
|
export { MoreHorizontal as More }
|
||||||
|
export { Share2 as Share }
|
||||||
|
export { ArrowUpDown as Swap }
|
||||||
|
export { LinkIcon as Link }
|
||||||
|
export { X as Close }
|
||||||
|
|
||||||
|
const make = (Icon: typeof Mic) => (size = 16) => <Icon size={size} strokeWidth={1.75} />
|
||||||
|
|
||||||
|
export const I = {
|
||||||
|
Inbox: make(Inbox),
|
||||||
|
Mic: make(Mic),
|
||||||
|
Upload: make(Upload),
|
||||||
|
Radio: make(Radio),
|
||||||
|
Door: make(DoorClosed),
|
||||||
|
Folder: make(Folder),
|
||||||
|
Trash: make(Trash2),
|
||||||
|
Tag: make(Tag),
|
||||||
|
Users: make(Users),
|
||||||
|
Search: make(Search),
|
||||||
|
Plus: make(Plus),
|
||||||
|
Bell: make(Bell),
|
||||||
|
Settings: make(Settings),
|
||||||
|
Close: make(X),
|
||||||
|
Download: make(Download),
|
||||||
|
Share: make(Share2),
|
||||||
|
More: make(MoreHorizontal),
|
||||||
|
Globe: make(Globe),
|
||||||
|
Clock: make(Clock),
|
||||||
|
CheckCircle: make(CheckCircle2),
|
||||||
|
AlertTriangle: make(AlertTriangle),
|
||||||
|
Loader: make(Loader),
|
||||||
|
ChevronDown: make(ChevronDown),
|
||||||
|
ChevronLeft: make(ChevronLeft),
|
||||||
|
ChevronRight: make(ChevronRight),
|
||||||
|
Sparkle: make(Sparkles),
|
||||||
|
Waves: make(Waves),
|
||||||
|
Filter: make(Filter),
|
||||||
|
Undo: make(Undo),
|
||||||
|
Edit: make(Edit),
|
||||||
|
Refresh: make(RefreshCw),
|
||||||
|
ExternalLink: make(ExternalLink),
|
||||||
|
RotateCcw: make(RotateCcw),
|
||||||
|
X: make(X),
|
||||||
|
Info: make(Info),
|
||||||
|
Check: make(Check),
|
||||||
|
Moon: make(Moon),
|
||||||
|
Sun: make(Sun),
|
||||||
|
Lock: make(Lock),
|
||||||
|
Shield: make(Shield),
|
||||||
|
Swap: make(ArrowUpDown),
|
||||||
|
ArrowRight: make(ArrowRight),
|
||||||
|
History: make(History),
|
||||||
|
DoorOpen: make(DoorOpen),
|
||||||
|
FileAudio: make(FileAudio),
|
||||||
|
File: make(File),
|
||||||
|
Calendar: make(Calendar),
|
||||||
|
Link: make(LinkIcon),
|
||||||
|
Cloud: make(Cloud),
|
||||||
|
User: make(User),
|
||||||
|
Copy: make(Copy),
|
||||||
|
}
|
||||||
37
ui/src/components/layout/AppShell.tsx
Normal file
37
ui/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import { TopBar } from './TopBar'
|
||||||
|
|
||||||
|
type AppShellProps = {
|
||||||
|
title: string
|
||||||
|
crumb?: string[]
|
||||||
|
sidebar?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ title, crumb, sidebar, children }: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebar}
|
||||||
|
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<TopBar title={title} crumb={crumb} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: 24,
|
||||||
|
background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
331
ui/src/components/layout/AppSidebar.tsx
Normal file
331
ui/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button, SectionLabel, SidebarItem } from '@/components/ui/primitives'
|
||||||
|
import type { RoomRowData, SidebarFilter, TagRowData } from '@/lib/types'
|
||||||
|
import { BrandHeader, PrimaryNav, UserChip, sidebarAsideStyle } from './sidebarChrome'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
|
||||||
|
type AppSidebarProps = {
|
||||||
|
filter: SidebarFilter
|
||||||
|
onFilter: (filter: SidebarFilter) => void
|
||||||
|
rooms: RoomRowData[]
|
||||||
|
tags: TagRowData[]
|
||||||
|
showTags?: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onNewRecording?: () => void
|
||||||
|
counts?: {
|
||||||
|
all?: number | null
|
||||||
|
liveTranscripts?: number | null
|
||||||
|
uploadedFiles?: number | null
|
||||||
|
trash?: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({
|
||||||
|
filter,
|
||||||
|
onFilter,
|
||||||
|
rooms,
|
||||||
|
tags,
|
||||||
|
showTags = true,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
onNewRecording,
|
||||||
|
counts,
|
||||||
|
}: AppSidebarProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const myRooms = rooms.filter((r) => !r.shared)
|
||||||
|
const sharedRooms = rooms.filter((r) => r.shared)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={sidebarAsideStyle(collapsed) as CSSProperties}>
|
||||||
|
<BrandHeader collapsed={collapsed} onToggle={onToggle} />
|
||||||
|
|
||||||
|
{collapsed ? (
|
||||||
|
<CollapsedRail
|
||||||
|
filter={filter}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onNewRecording={onNewRecording}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExpandedNav
|
||||||
|
filter={filter}
|
||||||
|
onFilter={onFilter}
|
||||||
|
myRooms={myRooms}
|
||||||
|
sharedRooms={sharedRooms}
|
||||||
|
tags={tags}
|
||||||
|
showTags={showTags}
|
||||||
|
onNewRecording={onNewRecording}
|
||||||
|
counts={counts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!collapsed && <UserChip user={user} />}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpandedNavProps = {
|
||||||
|
filter: SidebarFilter
|
||||||
|
onFilter: (filter: SidebarFilter) => void
|
||||||
|
myRooms: RoomRowData[]
|
||||||
|
sharedRooms: RoomRowData[]
|
||||||
|
tags: TagRowData[]
|
||||||
|
showTags?: boolean
|
||||||
|
onNewRecording?: () => void
|
||||||
|
counts?: AppSidebarProps['counts']
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandedNav({
|
||||||
|
filter,
|
||||||
|
onFilter,
|
||||||
|
myRooms,
|
||||||
|
sharedRooms,
|
||||||
|
tags,
|
||||||
|
showTags = true,
|
||||||
|
onNewRecording,
|
||||||
|
counts,
|
||||||
|
}: ExpandedNavProps) {
|
||||||
|
const isActive = (kind: SidebarFilter['kind'], val: SidebarFilter['value'] = null) =>
|
||||||
|
filter.kind === kind && filter.value === val
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '14px 12px 6px' }}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
|
onClick={onNewRecording}
|
||||||
|
>
|
||||||
|
{I.Mic(14)} New recording
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 10px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PrimaryNav />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
background: 'var(--border)',
|
||||||
|
margin: '2px 6px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Inbox(15)}
|
||||||
|
label="All transcripts"
|
||||||
|
count={counts?.all ?? null}
|
||||||
|
active={isActive('all')}
|
||||||
|
onClick={() => onFilter({ kind: 'all', value: null })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Sparkle(15)}
|
||||||
|
label="Recent"
|
||||||
|
active={isActive('recent')}
|
||||||
|
onClick={() => onFilter({ kind: 'recent', value: null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Sources</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Radio(15)}
|
||||||
|
label="Live transcripts"
|
||||||
|
dot={
|
||||||
|
filter.kind === 'source' && filter.value === 'live'
|
||||||
|
? undefined
|
||||||
|
: 'var(--status-live)'
|
||||||
|
}
|
||||||
|
count={counts?.liveTranscripts ?? null}
|
||||||
|
active={isActive('source', 'live')}
|
||||||
|
onClick={() => onFilter({ kind: 'source', value: 'live' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Upload(15)}
|
||||||
|
label="Uploaded files"
|
||||||
|
count={counts?.uploadedFiles ?? null}
|
||||||
|
active={isActive('source', 'file')}
|
||||||
|
onClick={() => onFilter({ kind: 'source', value: 'file' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myRooms.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel
|
||||||
|
action={
|
||||||
|
<span style={{ color: 'var(--fg-muted)', cursor: 'pointer', opacity: 0.6 }}>+</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
My rooms
|
||||||
|
</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{myRooms.map((r) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={r.id}
|
||||||
|
icon={I.Door(15)}
|
||||||
|
label={r.name}
|
||||||
|
count={r.count}
|
||||||
|
active={isActive('room', r.id)}
|
||||||
|
onClick={() => onFilter({ kind: 'room', value: r.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharedRooms.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Shared</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{sharedRooms.map((r) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={r.id}
|
||||||
|
icon={I.Users(14)}
|
||||||
|
label={r.name}
|
||||||
|
count={r.count}
|
||||||
|
active={isActive('room', r.id)}
|
||||||
|
onClick={() => onFilter({ kind: 'room', value: r.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTags && tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel
|
||||||
|
action={
|
||||||
|
<span style={{ color: 'var(--fg-muted)', cursor: 'pointer', opacity: 0.6 }}>+</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={t.id}
|
||||||
|
icon={I.Tag(14)}
|
||||||
|
label={t.name}
|
||||||
|
count={t.count}
|
||||||
|
active={isActive('tag', t.id)}
|
||||||
|
onClick={() => onFilter({ kind: 'tag', value: t.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', borderTop: '1px solid var(--border)', paddingTop: 10 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Trash(15)}
|
||||||
|
label="Trash"
|
||||||
|
active={isActive('trash')}
|
||||||
|
onClick={() => onFilter({ kind: 'trash', value: null })}
|
||||||
|
count={counts?.trash ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollapsedRailProps = {
|
||||||
|
filter: SidebarFilter
|
||||||
|
onFilter: (filter: SidebarFilter) => void
|
||||||
|
onToggle: () => void
|
||||||
|
onNewRecording?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedRail({ filter, onFilter, onToggle, onNewRecording }: CollapsedRailProps) {
|
||||||
|
const items: Array<{
|
||||||
|
kind: SidebarFilter['kind']
|
||||||
|
value?: SidebarFilter['value']
|
||||||
|
icon: ReturnType<typeof I.Inbox>
|
||||||
|
title: string
|
||||||
|
}> = [
|
||||||
|
{ kind: 'all', icon: I.Inbox(18), title: 'All' },
|
||||||
|
{ kind: 'recent', icon: I.Sparkle(18), title: 'Recent' },
|
||||||
|
{ kind: 'source', value: 'live', icon: I.Radio(18), title: 'Live' },
|
||||||
|
{ kind: 'source', value: 'file', icon: I.Upload(18), title: 'Uploads' },
|
||||||
|
{ kind: 'trash', icon: I.Trash(18), title: 'Trash' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="primary" size="icon" title="New recording" onClick={onNewRecording}>
|
||||||
|
{I.Mic(16)}
|
||||||
|
</Button>
|
||||||
|
<div style={{ height: 10 }} />
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const on = filter.kind === it.kind && (filter.value ?? null) === (it.value ?? null)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
title={it.title}
|
||||||
|
onClick={() =>
|
||||||
|
onFilter({ kind: it.kind, value: (it.value ?? null) as never } as SidebarFilter)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: on ? 'var(--border)' : 'transparent',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: on ? 'var(--card)' : 'transparent',
|
||||||
|
color: on ? 'var(--primary)' : 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: on ? 'var(--shadow-xs)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 'auto' }}>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
title="Expand sidebar"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronRight(16)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
ui/src/components/layout/ReflectorMark.tsx
Normal file
22
ui/src/components/layout/ReflectorMark.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export function ReflectorMark({ size = 28 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'block', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="227.5,51.5 86.5,150.1 100.8,383.9 244.3,249.8"
|
||||||
|
fill="var(--fg)"
|
||||||
|
opacity="0.82"
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
points="305.4,421.4 423.9,286 244.3,249.8 100.8,383.9"
|
||||||
|
fill="var(--fg)"
|
||||||
|
opacity="0.42"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
313
ui/src/components/layout/RoomsSidebar.tsx
Normal file
313
ui/src/components/layout/RoomsSidebar.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button, SectionLabel, SidebarItem } from '@/components/ui/primitives'
|
||||||
|
import type { RoomsFilter } from '@/lib/types'
|
||||||
|
import { BrandHeader, PrimaryNav, UserChip, sidebarAsideStyle } from './sidebarChrome'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
|
||||||
|
type Room = components['schemas']['RoomDetails']
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filter: RoomsFilter
|
||||||
|
onFilter: (f: RoomsFilter) => void
|
||||||
|
rooms: Room[]
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onNewRecording?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_COLOR: Record<Room['platform'], string> = {
|
||||||
|
whereby: 'var(--status-processing)',
|
||||||
|
daily: 'var(--status-ok)',
|
||||||
|
livekit: 'var(--primary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORMS: Room['platform'][] = ['whereby', 'daily', 'livekit']
|
||||||
|
|
||||||
|
export function RoomsSidebar({
|
||||||
|
filter,
|
||||||
|
onFilter,
|
||||||
|
rooms,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
onNewRecording,
|
||||||
|
}: Props) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isActive = (
|
||||||
|
kind: RoomsFilter['kind'],
|
||||||
|
val: RoomsFilter['value'] | null = null,
|
||||||
|
) => filter.kind === kind && (filter.value ?? null) === val
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
all: rooms.length,
|
||||||
|
mine: rooms.filter((r) => !r.is_shared).length,
|
||||||
|
shared: rooms.filter((r) => r.is_shared).length,
|
||||||
|
calendar: rooms.filter((r) => r.ics_enabled).length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformCount = (p: Room['platform']) =>
|
||||||
|
rooms.filter((r) => r.platform === p).length
|
||||||
|
const sizeCount = (s: string) => rooms.filter((r) => r.room_mode === s).length
|
||||||
|
const recCount = (t: string) => rooms.filter((r) => r.recording_type === t).length
|
||||||
|
|
||||||
|
const presentPlatforms = PLATFORMS.filter((p) => platformCount(p) > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={sidebarAsideStyle(collapsed) as CSSProperties}>
|
||||||
|
<BrandHeader collapsed={collapsed} onToggle={onToggle} />
|
||||||
|
|
||||||
|
{collapsed ? (
|
||||||
|
<RoomsRail
|
||||||
|
filter={filter}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onNewRecording={onNewRecording}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '14px 12px 6px' }}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
|
onClick={onNewRecording}
|
||||||
|
>
|
||||||
|
{I.Mic(14)} New recording
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 10px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PrimaryNav />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
background: 'var(--border)',
|
||||||
|
margin: '2px 6px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Door(15)}
|
||||||
|
label="All rooms"
|
||||||
|
count={counts.all}
|
||||||
|
active={isActive('all')}
|
||||||
|
onClick={() => onFilter({ kind: 'all', value: null })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.User(14)}
|
||||||
|
label="My rooms"
|
||||||
|
count={counts.mine}
|
||||||
|
active={isActive('scope', 'mine')}
|
||||||
|
onClick={() => onFilter({ kind: 'scope', value: 'mine' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Share(14)}
|
||||||
|
label="Shared"
|
||||||
|
count={counts.shared}
|
||||||
|
active={isActive('scope', 'shared')}
|
||||||
|
onClick={() => onFilter({ kind: 'scope', value: 'shared' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Status</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Radio(14)}
|
||||||
|
label="Active now"
|
||||||
|
dot="var(--status-live)"
|
||||||
|
count={0}
|
||||||
|
active={isActive('status', 'active')}
|
||||||
|
onClick={() => onFilter({ kind: 'status', value: 'active' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Calendar(14)}
|
||||||
|
label="Calendar-linked"
|
||||||
|
count={counts.calendar}
|
||||||
|
active={isActive('status', 'calendar')}
|
||||||
|
onClick={() => onFilter({ kind: 'status', value: 'calendar' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presentPlatforms.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Platform</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{presentPlatforms.map((p) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={p}
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: PLATFORM_COLOR[p],
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={p.charAt(0).toUpperCase() + p.slice(1)}
|
||||||
|
count={platformCount(p)}
|
||||||
|
active={isActive('platform', p)}
|
||||||
|
onClick={() => onFilter({ kind: 'platform', value: p })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Size</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.User(14)}
|
||||||
|
label="2–4 people"
|
||||||
|
count={sizeCount('normal')}
|
||||||
|
active={isActive('size', 'normal')}
|
||||||
|
onClick={() => onFilter({ kind: 'size', value: 'normal' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Users(14)}
|
||||||
|
label="2–200 people"
|
||||||
|
count={sizeCount('group')}
|
||||||
|
active={isActive('size', 'group')}
|
||||||
|
onClick={() => onFilter({ kind: 'size', value: 'group' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Recording</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Cloud(14)}
|
||||||
|
label="Cloud"
|
||||||
|
count={recCount('cloud')}
|
||||||
|
active={isActive('recording', 'cloud')}
|
||||||
|
onClick={() => onFilter({ kind: 'recording', value: 'cloud' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Download(14)}
|
||||||
|
label="Local"
|
||||||
|
count={recCount('local')}
|
||||||
|
active={isActive('recording', 'local')}
|
||||||
|
onClick={() => onFilter({ kind: 'recording', value: 'local' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.X(14)}
|
||||||
|
label="None"
|
||||||
|
count={recCount('none')}
|
||||||
|
active={isActive('recording', 'none')}
|
||||||
|
onClick={() => onFilter({ kind: 'recording', value: 'none' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<UserChip user={user} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RailProps = {
|
||||||
|
filter: RoomsFilter
|
||||||
|
onFilter: (f: RoomsFilter) => void
|
||||||
|
onToggle: () => void
|
||||||
|
onNewRecording?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomsRail({ filter, onFilter, onToggle, onNewRecording }: RailProps) {
|
||||||
|
const items: Array<{
|
||||||
|
kind: RoomsFilter['kind']
|
||||||
|
value: RoomsFilter['value'] | null
|
||||||
|
icon: ReturnType<typeof I.Door>
|
||||||
|
title: string
|
||||||
|
}> = [
|
||||||
|
{ kind: 'all', value: null, icon: I.Door(18), title: 'All rooms' },
|
||||||
|
{ kind: 'scope', value: 'mine', icon: I.User(18), title: 'My rooms' },
|
||||||
|
{ kind: 'scope', value: 'shared', icon: I.Share(18), title: 'Shared' },
|
||||||
|
{ kind: 'status', value: 'active', icon: I.Radio(18), title: 'Active' },
|
||||||
|
{ kind: 'status', value: 'calendar', icon: I.Calendar(18), title: 'Calendar' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="primary" size="icon" title="New recording" onClick={onNewRecording}>
|
||||||
|
{I.Mic(16)}
|
||||||
|
</Button>
|
||||||
|
<div style={{ height: 10 }} />
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const on =
|
||||||
|
filter.kind === it.kind && (filter.value ?? null) === (it.value ?? null)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
title={it.title}
|
||||||
|
onClick={() =>
|
||||||
|
onFilter({ kind: it.kind, value: it.value } as RoomsFilter)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: on ? 'var(--border)' : 'transparent',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: on ? 'var(--card)' : 'transparent',
|
||||||
|
color: on ? 'var(--primary)' : 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: on ? 'var(--shadow-xs)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 'auto' }}>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
title="Expand sidebar"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronRight(16)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
ui/src/components/layout/TopBar.tsx
Normal file
99
ui/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type TopBarProps = {
|
||||||
|
title: string
|
||||||
|
crumb?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ title, crumb }: TopBarProps) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
height: 65,
|
||||||
|
background: 'var(--card)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
gap: 16,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
paddingBottom: 10,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crumb && crumb.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crumb.map((c, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: i === crumb.length - 1 ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
{i < crumb.length - 1 && (
|
||||||
|
<span style={{ color: 'var(--gh-grey-4)' }}>/</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" title="Notifications">
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
|
{I.Bell(16)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: 'var(--primary)',
|
||||||
|
border: '1.5px solid var(--card)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
330
ui/src/components/layout/sidebarChrome.tsx
Normal file
330
ui/src/components/layout/sidebarChrome.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { SidebarItem } from '@/components/ui/primitives'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
import { ReflectorMark } from './ReflectorMark'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level nav shared by AppSidebar and RoomsSidebar — sits above the
|
||||||
|
* filter/context sections, below the New Recording button.
|
||||||
|
*/
|
||||||
|
export function PrimaryNav() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const onTranscripts =
|
||||||
|
location.pathname === '/' ||
|
||||||
|
location.pathname.startsWith('/browse') ||
|
||||||
|
location.pathname.startsWith('/transcripts') ||
|
||||||
|
location.pathname.startsWith('/transcript/')
|
||||||
|
const onRooms = location.pathname.startsWith('/rooms')
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Inbox(15)}
|
||||||
|
label="Transcripts"
|
||||||
|
active={onTranscripts}
|
||||||
|
onClick={() => navigate('/browse')}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={I.Door(15)}
|
||||||
|
label="Rooms"
|
||||||
|
active={onRooms}
|
||||||
|
onClick={() => navigate('/rooms')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandHeader({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 65,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: collapsed ? '0' : '0 16px',
|
||||||
|
justifyContent: collapsed ? 'center' : 'space-between',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ReflectorMark size={28} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<ReflectorMark size={26} />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reflector
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
by Greyhaven
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
title="Collapse sidebar"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronLeft(14)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserChip({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: { name?: string | null; email?: string | null } | null | undefined
|
||||||
|
}) {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const displayName = user?.name || user?.email || 'Signed in'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
style={{ borderTop: '1px solid var(--border)', padding: 12, position: 'relative' }}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: 'calc(100% - 6px)',
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
padding: 4,
|
||||||
|
zIndex: 60,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuRow
|
||||||
|
icon={I.Settings(14)}
|
||||||
|
label="Settings"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<div style={{ height: 1, background: 'var(--border)', margin: '4px 2px' }} />
|
||||||
|
<MenuRow
|
||||||
|
icon={I.ExternalLink(14)}
|
||||||
|
label="Log out"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
void logout()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
boxShadow: 'var(--shadow-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: 'var(--gh-off-black)',
|
||||||
|
color: 'var(--gh-off-white)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials(displayName)}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.email ? 'signed in' : 'local · on-prem'}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
transform: open ? 'rotate(180deg)' : undefined,
|
||||||
|
transition: 'transform var(--dur-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronDown(14)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
danger,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
padding: '7px 10px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: disabled
|
||||||
|
? 'var(--fg-muted)'
|
||||||
|
: danger
|
||||||
|
? 'var(--destructive)'
|
||||||
|
: 'var(--fg)',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.style.background = danger
|
||||||
|
? 'color-mix(in oklch, var(--destructive) 10%, transparent)'
|
||||||
|
: 'var(--muted)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
color: danger ? 'var(--destructive)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string) {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||||
|
.join('') || 'R'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sidebarAsideStyle = (collapsed: boolean) =>
|
||||||
|
({
|
||||||
|
width: collapsed ? 64 : 252,
|
||||||
|
transition: 'width var(--dur-normal) var(--ease-default)',
|
||||||
|
background: 'var(--secondary)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}) as const
|
||||||
111
ui/src/components/rooms/DeleteRoomDialog.tsx
Normal file
111
ui/src/components/rooms/DeleteRoomDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteRoomDialog({ name, onClose, onConfirm, loading }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', k)
|
||||||
|
return () => document.removeEventListener('keydown', k)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ width: 'min(440px, calc(100vw - 32px))' }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'color-mix(in srgb, var(--destructive) 12%, transparent)',
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Trash(18)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete room?
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '6px 0 0',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--fg)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
/{name}
|
||||||
|
</strong>{' '}
|
||||||
|
will be permanently removed. Existing recordings from this room are not affected.
|
||||||
|
This can't be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="md"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ color: 'var(--fg)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: 'var(--destructive)',
|
||||||
|
color: 'var(--destructive-fg)',
|
||||||
|
borderColor: 'var(--destructive)',
|
||||||
|
boxShadow: 'var(--shadow-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Trash(14)} {loading ? 'Deleting…' : 'Delete room'}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
834
ui/src/components/rooms/RoomFormDialog.tsx
Normal file
834
ui/src/components/rooms/RoomFormDialog.tsx
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
import { Combobox } from '@/components/ui/Combobox'
|
||||||
|
|
||||||
|
type Room = components['schemas']['RoomDetails']
|
||||||
|
|
||||||
|
export type RoomFormPayload = {
|
||||||
|
name: string
|
||||||
|
platform: 'whereby' | 'daily' | 'livekit'
|
||||||
|
room_mode: string
|
||||||
|
recording_type: string
|
||||||
|
recording_trigger: string
|
||||||
|
is_locked: boolean
|
||||||
|
is_shared: boolean
|
||||||
|
skip_consent: boolean
|
||||||
|
store_video: boolean
|
||||||
|
zulip_auto_post: boolean
|
||||||
|
zulip_stream: string
|
||||||
|
zulip_topic: string
|
||||||
|
webhook_url: string
|
||||||
|
webhook_secret: string
|
||||||
|
ics_url: string | null
|
||||||
|
ics_enabled: boolean
|
||||||
|
ics_fetch_interval: number
|
||||||
|
email_transcript_to: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
room: Room | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (payload: RoomFormPayload) => Promise<void>
|
||||||
|
saving?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_RE = /^[a-z0-9-_]+$/i
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'calendar', label: 'Calendar' },
|
||||||
|
{ id: 'share', label: 'Share' },
|
||||||
|
{ id: 'webhook', label: 'WebHook' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]['id']
|
||||||
|
|
||||||
|
export function RoomFormDialog({ room, onClose, onSave, saving }: Props) {
|
||||||
|
const isEdit = !!room
|
||||||
|
const [tab, setTab] = useState<TabId>('general')
|
||||||
|
|
||||||
|
const [name, setName] = useState(room?.name ?? '')
|
||||||
|
const [platform, setPlatform] = useState<Room['platform']>(room?.platform ?? 'whereby')
|
||||||
|
const [roomMode, setRoomMode] = useState(room?.room_mode ?? 'normal')
|
||||||
|
const [recType, setRecType] = useState(room?.recording_type ?? 'cloud')
|
||||||
|
const [recTrigger, setRecTrigger] = useState(
|
||||||
|
room?.recording_trigger ?? 'automatic-2nd-participant',
|
||||||
|
)
|
||||||
|
const [isLocked, setIsLocked] = useState(room?.is_locked ?? false)
|
||||||
|
const [isShared, setIsShared] = useState(room?.is_shared ?? false)
|
||||||
|
const [skipConsent, setSkipConsent] = useState(room?.skip_consent ?? false)
|
||||||
|
const [storeVideo, setStoreVideo] = useState(room?.store_video ?? false)
|
||||||
|
|
||||||
|
const [icsEnabled, setIcsEnabled] = useState(room?.ics_enabled ?? false)
|
||||||
|
const [icsUrl, setIcsUrl] = useState(room?.ics_url ?? '')
|
||||||
|
const [icsFetchInterval, setIcsFetchInterval] = useState(room?.ics_fetch_interval ?? 5)
|
||||||
|
|
||||||
|
const [zulipAutoPost, setZulipAutoPost] = useState(room?.zulip_auto_post ?? false)
|
||||||
|
const [zulipStream, setZulipStream] = useState(room?.zulip_stream ?? '')
|
||||||
|
const [zulipTopic, setZulipTopic] = useState(room?.zulip_topic ?? '')
|
||||||
|
|
||||||
|
const [webhookUrl, setWebhookUrl] = useState(room?.webhook_url ?? '')
|
||||||
|
const [webhookSecret, setWebhookSecret] = useState(room?.webhook_secret ?? '')
|
||||||
|
|
||||||
|
const [emailTranscriptTo, setEmailTranscriptTo] = useState(room?.email_transcript_to ?? '')
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['config'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET('/v1/config')
|
||||||
|
if (!response.ok || !data) throw new Error('Config unavailable')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const zulipEnabled = configQuery.data?.zulip_enabled ?? false
|
||||||
|
const emailEnabled = configQuery.data?.email_enabled ?? false
|
||||||
|
|
||||||
|
const visibleTabs = TABS.filter((t) => t.id !== 'share' || zulipEnabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleTabs.some((t) => t.id === tab)) setTab('general')
|
||||||
|
}, [visibleTabs, tab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !saving) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', k)
|
||||||
|
return () => document.removeEventListener('keydown', k)
|
||||||
|
}, [onClose, saving])
|
||||||
|
|
||||||
|
const nameError =
|
||||||
|
!isEdit && name && !NAME_RE.test(name)
|
||||||
|
? 'No spaces or special characters allowed'
|
||||||
|
: ''
|
||||||
|
const canSave = name.trim().length > 0 && !nameError && !saving
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setFormError(null)
|
||||||
|
if (!canSave) return
|
||||||
|
try {
|
||||||
|
const effectivePlatform = platform
|
||||||
|
const effectiveRoomMode = effectivePlatform === 'daily' ? 'group' : roomMode
|
||||||
|
const effectiveTrigger =
|
||||||
|
effectivePlatform === 'daily'
|
||||||
|
? recType === 'cloud'
|
||||||
|
? 'automatic-2nd-participant'
|
||||||
|
: 'none'
|
||||||
|
: recTrigger
|
||||||
|
await onSave({
|
||||||
|
name,
|
||||||
|
platform: effectivePlatform,
|
||||||
|
room_mode: effectiveRoomMode,
|
||||||
|
recording_type: recType,
|
||||||
|
recording_trigger: effectiveTrigger,
|
||||||
|
is_locked: isLocked,
|
||||||
|
is_shared: isShared,
|
||||||
|
skip_consent: skipConsent,
|
||||||
|
store_video: storeVideo,
|
||||||
|
zulip_auto_post: zulipAutoPost,
|
||||||
|
zulip_stream: zulipStream,
|
||||||
|
zulip_topic: zulipTopic,
|
||||||
|
webhook_url: webhookUrl,
|
||||||
|
webhook_secret: webhookSecret,
|
||||||
|
ics_url: icsUrl || null,
|
||||||
|
ics_enabled: icsEnabled,
|
||||||
|
ics_fetch_interval: icsFetchInterval,
|
||||||
|
email_transcript_to: emailTranscriptTo || null,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
padding: 20,
|
||||||
|
overflow: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={() => !saving && onClose()} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="rf-room-title"
|
||||||
|
style={{ width: 'min(600px, calc(100vw - 32px))' }}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void submit()
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{ padding: '18px 20px 0', display: 'flex', alignItems: 'flex-start' }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h2
|
||||||
|
id="rf-room-title"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEdit ? 'Edit room' : 'New room'}
|
||||||
|
</h2>
|
||||||
|
{isEdit && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '2px 0 0',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/{room!.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.X(16)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0,
|
||||||
|
padding: '14px 20px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleTabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: '8px 14px 10px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: tab === t.id ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: -1,
|
||||||
|
borderBottom: '2px solid',
|
||||||
|
borderBottomColor: tab === t.id ? 'var(--primary)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
margin: '12px 20px 0',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
background: 'color-mix(in srgb, var(--destructive) 8%, transparent)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--destructive) 25%, transparent)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={panelStyle}>
|
||||||
|
{tab === 'general' && (
|
||||||
|
<GeneralTab
|
||||||
|
name={name}
|
||||||
|
setName={setName}
|
||||||
|
nameError={nameError}
|
||||||
|
isEdit={isEdit}
|
||||||
|
platform={platform}
|
||||||
|
setPlatform={setPlatform}
|
||||||
|
isLocked={isLocked}
|
||||||
|
setIsLocked={setIsLocked}
|
||||||
|
roomMode={roomMode}
|
||||||
|
setRoomMode={setRoomMode}
|
||||||
|
recType={recType}
|
||||||
|
setRecType={setRecType}
|
||||||
|
recTrigger={recTrigger}
|
||||||
|
setRecTrigger={setRecTrigger}
|
||||||
|
isShared={isShared}
|
||||||
|
setIsShared={setIsShared}
|
||||||
|
skipConsent={skipConsent}
|
||||||
|
setSkipConsent={setSkipConsent}
|
||||||
|
storeVideo={storeVideo}
|
||||||
|
setStoreVideo={setStoreVideo}
|
||||||
|
emailEnabled={emailEnabled}
|
||||||
|
emailTranscriptTo={emailTranscriptTo}
|
||||||
|
setEmailTranscriptTo={setEmailTranscriptTo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'calendar' && (
|
||||||
|
<CalendarTab
|
||||||
|
icsEnabled={icsEnabled}
|
||||||
|
setIcsEnabled={setIcsEnabled}
|
||||||
|
icsUrl={icsUrl}
|
||||||
|
setIcsUrl={setIcsUrl}
|
||||||
|
icsFetchInterval={icsFetchInterval}
|
||||||
|
setIcsFetchInterval={setIcsFetchInterval}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'share' && (
|
||||||
|
<ShareTab
|
||||||
|
zulipEnabled={zulipEnabled}
|
||||||
|
zulipAutoPost={zulipAutoPost}
|
||||||
|
setZulipAutoPost={setZulipAutoPost}
|
||||||
|
zulipStream={zulipStream}
|
||||||
|
setZulipStream={setZulipStream}
|
||||||
|
zulipTopic={zulipTopic}
|
||||||
|
setZulipTopic={setZulipTopic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'webhook' && (
|
||||||
|
<WebhookTab
|
||||||
|
webhookUrl={webhookUrl}
|
||||||
|
setWebhookUrl={setWebhookUrl}
|
||||||
|
webhookSecret={webhookSecret}
|
||||||
|
setWebhookSecret={setWebhookSecret}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="md"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ color: 'var(--fg)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
disabled={!canSave}
|
||||||
|
style={!canSave ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add room'}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Field primitives ---------- */
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
info,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
info?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{label}
|
||||||
|
{info && (
|
||||||
|
<span
|
||||||
|
title={info}
|
||||||
|
style={{ display: 'inline-flex', color: 'var(--fg-muted)', cursor: 'help' }}
|
||||||
|
>
|
||||||
|
{I.Info(12)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div style={{ marginTop: 6 }}>{children}</div>
|
||||||
|
{hint && <div className="rf-hint">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
label: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
padding: '6px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 1,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1.5px solid',
|
||||||
|
borderColor: checked ? 'var(--primary)' : 'var(--gh-grey-4)',
|
||||||
|
background: checked ? 'var(--primary)' : 'var(--card)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--primary-fg)',
|
||||||
|
transition: 'all var(--dur-fast)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }}
|
||||||
|
/>
|
||||||
|
{checked && I.Check(11)}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--fg)', fontWeight: 500 }}>{label}</span>
|
||||||
|
{hint && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoBanner({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--primary)', marginTop: 1, flexShrink: 0 }}>
|
||||||
|
{I.Info(14)}
|
||||||
|
</span>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tabs ---------- */
|
||||||
|
|
||||||
|
type GeneralTabProps = {
|
||||||
|
name: string
|
||||||
|
setName: (v: string) => void
|
||||||
|
nameError: string
|
||||||
|
isEdit: boolean
|
||||||
|
platform: Room['platform']
|
||||||
|
setPlatform: (v: Room['platform']) => void
|
||||||
|
isLocked: boolean
|
||||||
|
setIsLocked: (v: boolean) => void
|
||||||
|
roomMode: string
|
||||||
|
setRoomMode: (v: string) => void
|
||||||
|
recType: string
|
||||||
|
setRecType: (v: string) => void
|
||||||
|
recTrigger: string
|
||||||
|
setRecTrigger: (v: string) => void
|
||||||
|
isShared: boolean
|
||||||
|
setIsShared: (v: boolean) => void
|
||||||
|
skipConsent: boolean
|
||||||
|
setSkipConsent: (v: boolean) => void
|
||||||
|
storeVideo: boolean
|
||||||
|
setStoreVideo: (v: boolean) => void
|
||||||
|
emailEnabled: boolean
|
||||||
|
emailTranscriptTo: string
|
||||||
|
setEmailTranscriptTo: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralTab(p: GeneralTabProps) {
|
||||||
|
const isDaily = p.platform === 'daily'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
label="Room name"
|
||||||
|
hint={p.nameError || (!p.isEdit ? 'No spaces or special characters allowed' : undefined)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="text"
|
||||||
|
autoFocus={!p.isEdit}
|
||||||
|
disabled={p.isEdit}
|
||||||
|
placeholder="room-name"
|
||||||
|
value={p.name}
|
||||||
|
onChange={(e) => p.setName(e.target.value)}
|
||||||
|
style={p.nameError ? { borderColor: 'var(--destructive)' } : undefined}
|
||||||
|
/>
|
||||||
|
{p.isEdit && (
|
||||||
|
<div className="rf-hint" style={{ color: 'var(--fg-muted)' }}>
|
||||||
|
Room name can't be changed after creation.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Platform">
|
||||||
|
<select
|
||||||
|
className="rf-select"
|
||||||
|
value={p.platform}
|
||||||
|
onChange={(e) => p.setPlatform(e.target.value as Room['platform'])}
|
||||||
|
>
|
||||||
|
<option value="whereby">Whereby</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="livekit">LiveKit</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={p.isLocked}
|
||||||
|
onChange={p.setIsLocked}
|
||||||
|
label="Locked room"
|
||||||
|
hint="Only the host can admit participants."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isDaily && (
|
||||||
|
<FormField label="Room size">
|
||||||
|
<select
|
||||||
|
className="rf-select"
|
||||||
|
value={p.roomMode}
|
||||||
|
onChange={(e) => p.setRoomMode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="normal">2–4 people</option>
|
||||||
|
<option value="group">2–200 people</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Recording type"
|
||||||
|
info="Local recording stays on the host's device. Cloud recording uploads to Reflector."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="rf-select"
|
||||||
|
value={p.recType}
|
||||||
|
onChange={(e) => p.setRecType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="cloud">Cloud</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{p.recType !== 'none' && !isDaily && (
|
||||||
|
<FormField label="Recording start trigger" info="When should recording begin?">
|
||||||
|
<select
|
||||||
|
className="rf-select"
|
||||||
|
value={p.recTrigger}
|
||||||
|
onChange={(e) => p.setRecTrigger(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="none">Manual — host starts recording</option>
|
||||||
|
<option value="prompt">Prompt — ask the host to start</option>
|
||||||
|
<option value="automatic-2nd-participant">
|
||||||
|
Automatic — when a second participant joins
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={p.isShared}
|
||||||
|
onChange={p.setIsShared}
|
||||||
|
label="Shared room"
|
||||||
|
hint="Visible to everyone in the workspace."
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={p.skipConsent}
|
||||||
|
onChange={p.setSkipConsent}
|
||||||
|
label="Skip consent dialog"
|
||||||
|
hint="When enabled, participants won't be asked for recording consent. Audio will be stored automatically."
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={p.storeVideo}
|
||||||
|
onChange={p.setStoreVideo}
|
||||||
|
label="Store video"
|
||||||
|
hint="Keep the video track alongside audio. Increases storage cost."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{p.emailEnabled && (
|
||||||
|
<FormField
|
||||||
|
label="Email transcript to"
|
||||||
|
hint="Receive a copy of each transcript summary at this address."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="team@example.com"
|
||||||
|
value={p.emailTranscriptTo}
|
||||||
|
onChange={(e) => p.setEmailTranscriptTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarTabProps = {
|
||||||
|
icsEnabled: boolean
|
||||||
|
setIcsEnabled: (v: boolean) => void
|
||||||
|
icsUrl: string
|
||||||
|
setIcsUrl: (v: string) => void
|
||||||
|
icsFetchInterval: number
|
||||||
|
setIcsFetchInterval: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarTab(p: CalendarTabProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InfoBanner>
|
||||||
|
Reflector polls the calendar on the configured interval. Meeting titles from the feed
|
||||||
|
replace the generic "Meeting" label on recordings.
|
||||||
|
</InfoBanner>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={p.icsEnabled}
|
||||||
|
onChange={p.setIcsEnabled}
|
||||||
|
label="Enable calendar sync"
|
||||||
|
hint="Pull meeting titles from an ICS feed (Google Calendar, Outlook, Fastmail, etc.)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{p.icsEnabled && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
label="ICS feed URL"
|
||||||
|
hint="Paste the secret calendar URL from your provider. Keep it private."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
|
||||||
|
value={p.icsUrl}
|
||||||
|
onChange={(e) => p.setIcsUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Fetch interval" hint="Minutes between calendar syncs.">
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={p.icsFetchInterval}
|
||||||
|
onChange={(e) => p.setIcsFetchInterval(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareTabProps = {
|
||||||
|
zulipEnabled: boolean
|
||||||
|
zulipAutoPost: boolean
|
||||||
|
setZulipAutoPost: (v: boolean) => void
|
||||||
|
zulipStream: string
|
||||||
|
setZulipStream: (v: string) => void
|
||||||
|
zulipTopic: string
|
||||||
|
setZulipTopic: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareTab(p: ShareTabProps) {
|
||||||
|
const { data: streams = [] } = useQuery({
|
||||||
|
queryKey: ['zulip', 'streams'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET('/v1/zulip/streams')
|
||||||
|
if (!response.ok || !data) throw new Error('Failed to load Zulip streams')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: p.zulipEnabled,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
const selectedStreamId =
|
||||||
|
streams.find((s) => s.name === p.zulipStream)?.stream_id ?? null
|
||||||
|
const { data: topics = [] } = useQuery({
|
||||||
|
queryKey: ['zulip', 'topics', selectedStreamId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (selectedStreamId == null) return []
|
||||||
|
const { data, response } = await apiClient.GET(
|
||||||
|
'/v1/zulip/streams/{stream_id}/topics',
|
||||||
|
{ params: { path: { stream_id: selectedStreamId } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) throw new Error('Failed to load Zulip topics')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: p.zulipEnabled && selectedStreamId != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!p.zulipEnabled) {
|
||||||
|
return (
|
||||||
|
<InfoBanner>
|
||||||
|
Zulip integration isn't configured on this Reflector instance. Set <code>ZULIP_REALM</code>{' '}
|
||||||
|
and related env vars on the server to enable auto-posting transcript summaries.
|
||||||
|
</InfoBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InfoBanner>
|
||||||
|
Post the transcript summary + link to a Zulip channel when the meeting ends.
|
||||||
|
</InfoBanner>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={p.zulipAutoPost}
|
||||||
|
onChange={p.setZulipAutoPost}
|
||||||
|
label="Auto-post to Zulip"
|
||||||
|
hint="Send a summary message to a Zulip stream and topic after each meeting."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{p.zulipAutoPost && (
|
||||||
|
<>
|
||||||
|
<FormField label="Stream">
|
||||||
|
<Combobox
|
||||||
|
value={p.zulipStream}
|
||||||
|
onChange={(v) => {
|
||||||
|
p.setZulipStream(v)
|
||||||
|
p.setZulipTopic('')
|
||||||
|
}}
|
||||||
|
options={streams.map((s) => s.name)}
|
||||||
|
placeholder="e.g. reflector"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="Topic"
|
||||||
|
hint="The topic within the stream where messages will be posted."
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
value={p.zulipTopic}
|
||||||
|
onChange={p.setZulipTopic}
|
||||||
|
options={topics.map((t) => t.name)}
|
||||||
|
placeholder="e.g. Meeting notes"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookTabProps = {
|
||||||
|
webhookUrl: string
|
||||||
|
setWebhookUrl: (v: string) => void
|
||||||
|
webhookSecret: string
|
||||||
|
setWebhookSecret: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookTab(p: WebhookTabProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InfoBanner>
|
||||||
|
Reflector POSTs a JSON payload to your URL on lifecycle events:{' '}
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>meeting.started</code>,{' '}
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>meeting.ended</code>,{' '}
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>transcript.ready</code>.
|
||||||
|
</InfoBanner>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Webhook URL"
|
||||||
|
hint="HTTPS required. Signed with the webhook secret below."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/reflector/webhook"
|
||||||
|
value={p.webhookUrl}
|
||||||
|
onChange={(e) => p.setWebhookUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Webhook secret"
|
||||||
|
hint="Used to sign each payload (HMAC-SHA256) so your receiver can verify it."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="whsec_…"
|
||||||
|
value={p.webhookSecret}
|
||||||
|
onChange={(e) => p.setWebhookSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
344
ui/src/components/rooms/RoomsTable.tsx
Normal file
344
ui/src/components/rooms/RoomsTable.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button, RowMenuTrigger, StatusDot } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Room = components['schemas']['RoomDetails']
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rooms: Room[]
|
||||||
|
onEdit?: (room: Room) => void
|
||||||
|
onDelete?: (room: Room) => void
|
||||||
|
onCopy?: (room: Room) => void
|
||||||
|
copiedId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_COLOR: Record<Room['platform'], string> = {
|
||||||
|
whereby: 'var(--status-processing)',
|
||||||
|
daily: 'var(--status-ok)',
|
||||||
|
livekit: 'var(--primary)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(p: Room['platform']) {
|
||||||
|
return p.charAt(0).toUpperCase() + p.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomUrl(room: Room) {
|
||||||
|
return `${window.location.origin}/${room.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoom(room: Room) {
|
||||||
|
window.open(roomUrl(room), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomModeLabel(mode: string) {
|
||||||
|
if (mode === 'normal') return '2-4'
|
||||||
|
if (mode === 'group') return '2-200'
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingLabel(type: string, trigger: string | null | undefined) {
|
||||||
|
if (type === 'none') return null
|
||||||
|
if (type === 'local') return 'Local recording'
|
||||||
|
if (type === 'cloud') {
|
||||||
|
if (trigger === 'automatic-2nd-participant') return 'Cloud · auto'
|
||||||
|
if (trigger === 'prompt') return 'Cloud · prompt'
|
||||||
|
return 'Cloud'
|
||||||
|
}
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarSyncIcon({ size = 14 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', width: size, height: size }}>
|
||||||
|
{I.Calendar(size)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -3,
|
||||||
|
bottom: -3,
|
||||||
|
width: size * 0.65,
|
||||||
|
height: size * 0.65,
|
||||||
|
background: 'var(--card)',
|
||||||
|
borderRadius: 9999,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Refresh(size * 0.55)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomsTable({ rooms, onEdit, onDelete, onCopy, copiedId }: Props) {
|
||||||
|
if (rooms.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<RoomRow
|
||||||
|
key={r.id}
|
||||||
|
room={r}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCopy={onCopy}
|
||||||
|
copied={copiedId === r.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomRowProps = {
|
||||||
|
room: Room
|
||||||
|
onEdit?: (room: Room) => void
|
||||||
|
onDelete?: (room: Room) => void
|
||||||
|
onCopy?: (room: Room) => void
|
||||||
|
copied?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomRow({ room, onEdit, onDelete, onCopy, copied }: RoomRowProps) {
|
||||||
|
const recording = recordingLabel(room.recording_type, room.recording_trigger)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rf-row"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: 18,
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
|
||||||
|
<StatusDot status="idle" size={7} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
minWidth: 0,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
rowGap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={roomUrl(room)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 14.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontWeight: 500 }}>/</span>
|
||||||
|
<span>{room.name}</span>
|
||||||
|
</a>
|
||||||
|
{room.ics_enabled && (
|
||||||
|
<Pill icon={I.Calendar(10)} title="Calendar sync enabled">
|
||||||
|
Calendar
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
rowGap: 3,
|
||||||
|
columnGap: 0,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
display: 'inline-block',
|
||||||
|
background: PLATFORM_COLOR[room.platform],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{platformLabel(room.platform)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Dot />
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{I.Users(11)} {roomModeLabel(room.room_mode)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{recording && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{room.recording_type === 'cloud' ? I.Cloud(11) : I.Download(11)}
|
||||||
|
{recording}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{room.zulip_auto_post && room.zulip_stream && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'var(--gh-grey-5)',
|
||||||
|
color: 'var(--gh-off-white)',
|
||||||
|
borderRadius: 2,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Z
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{room.zulip_stream}
|
||||||
|
{room.zulip_topic && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--gh-grey-3)', margin: '0 4px' }}>›</span>
|
||||||
|
{room.zulip_topic}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{copied && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--status-ok)',
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontWeight: 600,
|
||||||
|
paddingRight: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 2 }}>
|
||||||
|
{room.ics_enabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
title="Force calendar sync"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CalendarSyncIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!copied && onCopy && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
title="Copy room URL"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCopy(room)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Link(14)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<RowMenuTrigger
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Open room',
|
||||||
|
icon: I.ExternalLink(14),
|
||||||
|
onClick: () => openRoom(room),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy URL',
|
||||||
|
icon: I.Link(14),
|
||||||
|
onClick: () => onCopy?.(room),
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: 'Edit settings',
|
||||||
|
icon: I.Edit(14),
|
||||||
|
onClick: () => onEdit?.(room),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete room',
|
||||||
|
icon: I.Trash(14),
|
||||||
|
onClick: () => onDelete?.(room),
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label="Room options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon?: ReactNode
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '1px 7px',
|
||||||
|
height: 18,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot() {
|
||||||
|
return <span style={{ margin: '0 10px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
}
|
||||||
237
ui/src/components/shared/NewTranscriptDialog.tsx
Normal file
237
ui/src/components/shared/NewTranscriptDialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { useRooms } from '@/hooks/useRooms'
|
||||||
|
import { REFLECTOR_LANGS } from '@/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewTranscriptDialog({ onClose }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: rooms = [] } = useRooms()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [sourceLang, setSourceLang] = useState('auto')
|
||||||
|
const [targetLang, setTargetLang] = useState('')
|
||||||
|
const [roomId, setRoomId] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !submitting) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', k)
|
||||||
|
return () => document.removeEventListener('keydown', k)
|
||||||
|
}, [onClose, submitting])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { data, response } = await apiClient.POST('/v1/transcripts', {
|
||||||
|
body: {
|
||||||
|
name: title || null,
|
||||||
|
source_language: sourceLang === 'auto' ? null : sourceLang,
|
||||||
|
target_language: targetLang || null,
|
||||||
|
room_id: roomId || null,
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
if (!response.ok || !data) throw new Error('Could not create transcript')
|
||||||
|
const id = (data as { id: string }).id
|
||||||
|
onClose()
|
||||||
|
navigate(`/browse?active=${id}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create transcript')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
toast.info('Upload flow lives on the transcript detail page — ship next pass.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={() => !submitting && onClose()} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="rf-new-title"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '18px 20px 14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h2
|
||||||
|
id="rf-new-title"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New transcript
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '2px 0 0',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Record live or upload a file. You can edit details later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.X(16)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 20,
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-nd-title">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rf-nd-title"
|
||||||
|
className="rf-input"
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. Sprint review — June 12"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-nd-source">
|
||||||
|
{I.Mic(13)} Spoken language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-nd-source"
|
||||||
|
className="rf-select"
|
||||||
|
value={sourceLang}
|
||||||
|
onChange={(e) => setSourceLang(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
{REFLECTOR_LANGS.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="rf-hint">Detected from the audio if set to Auto.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-nd-target">
|
||||||
|
{I.Globe(13)} Translate to
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-nd-target"
|
||||||
|
className="rf-select"
|
||||||
|
value={targetLang}
|
||||||
|
onChange={(e) => setTargetLang(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<option value="">— None (same as spoken) —</option>
|
||||||
|
{REFLECTOR_LANGS.filter((l) => l.code !== 'auto').map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="rf-hint">Leave blank to skip translation.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="rf-label" htmlFor="rf-nd-room">
|
||||||
|
{I.Folder(13)} Attach to room{' '}
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontWeight: 400 }}>— optional</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rf-nd-room"
|
||||||
|
className="rf-select"
|
||||||
|
value={roomId}
|
||||||
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Lock(12)}
|
||||||
|
Audio stays on your infrastructure.
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="md" onClick={handleUpload} disabled={submitting}>
|
||||||
|
{I.Upload(14)} Upload file
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="md" onClick={submit} disabled={submitting}>
|
||||||
|
{I.Mic(14)} {submitting ? 'Starting…' : 'Start recording'}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
219
ui/src/components/transcript/AudioPlayer.tsx
Normal file
219
ui/src/components/transcript/AudioPlayer.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { fmtDur } from '@/lib/format'
|
||||||
|
import { WaveformCanvas } from './WaveformCanvas'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcriptId: string
|
||||||
|
peaks: number[] | null | undefined
|
||||||
|
ticks?: number[]
|
||||||
|
/** Seconds. When set, the player seeks to this time. */
|
||||||
|
seekTarget?: { seconds: number; nonce: number } | null
|
||||||
|
onTimeUpdate?: (currentSeconds: number) => void
|
||||||
|
onDuration?: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authed audio playback for a transcript. We fetch the MP3 through the API
|
||||||
|
* client (so the Authorization header lands) and attach the blob URL to a
|
||||||
|
* native <audio> element. Limitation: loads the full file upfront, so this is
|
||||||
|
* fine for typical meetings. Upgrade to a service worker if the backend starts
|
||||||
|
* serving hour-long recordings.
|
||||||
|
*/
|
||||||
|
export function AudioPlayer({
|
||||||
|
transcriptId,
|
||||||
|
peaks,
|
||||||
|
ticks,
|
||||||
|
seekTarget,
|
||||||
|
onTimeUpdate,
|
||||||
|
onDuration,
|
||||||
|
}: Props) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
let url: string | null = null
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
// openapi-fetch will attach the Authorization header from our middleware.
|
||||||
|
// We use parseAs "stream" to get the raw Response, then read as a Blob.
|
||||||
|
const { response } = await apiClient.GET(
|
||||||
|
'/v1/transcripts/{transcript_id}/audio/mp3',
|
||||||
|
{
|
||||||
|
params: { path: { transcript_id: transcriptId } },
|
||||||
|
parseAs: 'stream',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error(`Audio fetch failed (${response.status})`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (cancelled) return
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
setBlobUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not load audio')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (url) URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}, [transcriptId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seekTarget || !audioRef.current || !duration) return
|
||||||
|
audioRef.current.currentTime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(duration - 0.05, seekTarget.seconds),
|
||||||
|
)
|
||||||
|
}, [seekTarget, duration])
|
||||||
|
|
||||||
|
// Keyboard: space toggles play/pause unless focus is in an input.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.code !== 'Space') return
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const tag = target?.tagName?.toLowerCase()
|
||||||
|
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
|
||||||
|
e.preventDefault()
|
||||||
|
const a = audioRef.current
|
||||||
|
if (!a) return
|
||||||
|
if (a.paused) a.play()
|
||||||
|
else a.pause()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSeekRatio = (ratio: number) => {
|
||||||
|
const a = audioRef.current
|
||||||
|
if (!a || !duration) return
|
||||||
|
a.currentTime = ratio * duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
padding: 14,
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
boxShadow: 'var(--shadow-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const a = audioRef.current
|
||||||
|
if (!a) return
|
||||||
|
if (a.paused) a.play()
|
||||||
|
else a.pause()
|
||||||
|
}}
|
||||||
|
disabled={loading || !!error}
|
||||||
|
title={playing ? 'Pause (Space)' : 'Play (Space)'}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
gap: 3,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 3, height: 12, background: 'currentColor' }} />
|
||||||
|
<span style={{ width: 3, height: 12, background: 'currentColor' }} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: '10px solid currentColor',
|
||||||
|
borderTop: '7px solid transparent',
|
||||||
|
borderBottom: '7px solid transparent',
|
||||||
|
marginLeft: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 12 }}>Loading audio…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.AlertTriangle(12)} {error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<WaveformCanvas
|
||||||
|
peaks={peaks}
|
||||||
|
progress={duration ? currentTime / duration : 0}
|
||||||
|
onSeek={handleSeekRatio}
|
||||||
|
ticks={ticks}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
minWidth: 88,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtDur(Math.floor(currentTime))} / {fmtDur(Math.floor(duration))}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{blobUrl && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={blobUrl}
|
||||||
|
preload="metadata"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const d = e.currentTarget.duration
|
||||||
|
setDuration(d)
|
||||||
|
onDuration?.(d)
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => {
|
||||||
|
const t = e.currentTarget.currentTime
|
||||||
|
setCurrentTime(t)
|
||||||
|
onTimeUpdate?.(t)
|
||||||
|
}}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
ui/src/components/transcript/Banners.tsx
Normal file
51
ui/src/components/transcript/Banners.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { I } from '@/components/icons'
|
||||||
|
|
||||||
|
export function ErrorBanner({ message }: { message: string | null | undefined }) {
|
||||||
|
const text = message?.trim() || 'Processing failed — reason unavailable.'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
background: 'color-mix(in oklch, var(--destructive) 8%, transparent)',
|
||||||
|
border: '1px solid color-mix(in oklch, var(--destructive) 22%, transparent)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginTop: 2, flexShrink: 0 }}>{I.AlertTriangle(14)}</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioDeletedBanner() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginTop: 2, flexShrink: 0 }}>{I.Lock(14)}</span>
|
||||||
|
<span>
|
||||||
|
No audio is available because one or more participants didn't consent to keep the
|
||||||
|
audio.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
ui/src/components/transcript/MetadataStrip.tsx
Normal file
99
ui/src/components/transcript/MetadataStrip.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { fmtDate, fmtDur } from '@/lib/format'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcript: Transcript
|
||||||
|
speakerCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(t: Transcript): string {
|
||||||
|
if (t.source_kind === 'room') return t.room_name || 'room'
|
||||||
|
if (t.source_kind === 'live') return 'live'
|
||||||
|
return 'upload'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeconds(value: number | null | undefined) {
|
||||||
|
if (!value) return 0
|
||||||
|
// Backend persists duration in ms in the `duration` column (see file_pipeline.py).
|
||||||
|
return Math.round(value / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot() {
|
||||||
|
return <span style={{ margin: '0 8px', color: 'var(--gh-grey-3)' }}>·</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadataStrip({ transcript, speakerCount }: Props) {
|
||||||
|
const src = transcript.source_language ?? ''
|
||||||
|
const tgt = transcript.target_language ?? null
|
||||||
|
const shortId = transcript.id.slice(0, 8)
|
||||||
|
const duration = toSeconds(transcript.duration)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
rowGap: 2,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>#{shortId}</span>
|
||||||
|
<Dot />
|
||||||
|
<span>{sourceLabel(transcript)}</span>
|
||||||
|
<Dot />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{fmtDate(transcript.created_at)}
|
||||||
|
</span>
|
||||||
|
<Dot />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{fmtDur(duration)}
|
||||||
|
</span>
|
||||||
|
{speakerCount > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{I.Users(11)} {speakerCount} {speakerCount === 1 ? 'speaker' : 'speakers'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{src && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
color: tgt && tgt !== src ? 'var(--primary)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Globe(11)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src}
|
||||||
|
{tgt && tgt !== src && <> → {tgt}</>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{transcript.room_name && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{I.Door(11)} {transcript.room_name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
520
ui/src/components/transcript/ShareDialog.tsx
Normal file
520
ui/src/components/transcript/ShareDialog.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
import { Combobox } from '@/components/ui/Combobox'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
type ShareMode = 'private' | 'semi-private' | 'public'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcript: Transcript
|
||||||
|
canEdit: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onChangeShareMode: (mode: ShareMode) => Promise<void>
|
||||||
|
onSendEmail: (email: string) => Promise<void>
|
||||||
|
onPostToZulip: (stream: string, topic: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_LABEL: Record<ShareMode, string> = {
|
||||||
|
private: 'Private',
|
||||||
|
'semi-private': 'Secure',
|
||||||
|
public: 'Public',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_HINT: Record<ShareMode, string> = {
|
||||||
|
private: 'Only you.',
|
||||||
|
'semi-private': 'Anyone signed into this Reflector instance.',
|
||||||
|
public: 'Anyone with the link.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareDialog(props: Props) {
|
||||||
|
return (
|
||||||
|
<DialogBoundary onClose={props.onClose}>
|
||||||
|
<ShareDialogInner {...props} />
|
||||||
|
</DialogBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareDialogInner({
|
||||||
|
transcript,
|
||||||
|
canEdit,
|
||||||
|
onClose,
|
||||||
|
onChangeShareMode,
|
||||||
|
onSendEmail,
|
||||||
|
onPostToZulip,
|
||||||
|
}: Props) {
|
||||||
|
const { data: config } = useQuery({
|
||||||
|
queryKey: ['config'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET('/v1/config')
|
||||||
|
if (!response.ok || !data) throw new Error('Config unavailable')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [emailInput, setEmailInput] = useState('')
|
||||||
|
const [sendingEmail, setSendingEmail] = useState(false)
|
||||||
|
const [stream, setStream] = useState('')
|
||||||
|
const [topic, setTopic] = useState('')
|
||||||
|
const [postingZulip, setPostingZulip] = useState(false)
|
||||||
|
const [modeBusy, setModeBusy] = useState(false)
|
||||||
|
|
||||||
|
const zulipEnabledForFetch = Boolean(
|
||||||
|
(config as { zulip_enabled?: boolean } | undefined)?.zulip_enabled,
|
||||||
|
)
|
||||||
|
const { data: zulipStreams = [] } = useQuery({
|
||||||
|
queryKey: ['zulip', 'streams'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET('/v1/zulip/streams')
|
||||||
|
if (!response.ok || !data) throw new Error('Failed to load Zulip streams')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: zulipEnabledForFetch,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
const selectedStreamId =
|
||||||
|
zulipStreams.find((s) => s.name === stream)?.stream_id ?? null
|
||||||
|
const { data: zulipTopics = [] } = useQuery({
|
||||||
|
queryKey: ['zulip', 'topics', selectedStreamId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (selectedStreamId == null) return []
|
||||||
|
const { data, response } = await apiClient.GET(
|
||||||
|
'/v1/zulip/streams/{stream_id}/topics',
|
||||||
|
{ params: { path: { stream_id: selectedStreamId } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) throw new Error('Failed to load Zulip topics')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: zulipEnabledForFetch && selectedStreamId != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', k)
|
||||||
|
return () => document.removeEventListener('keydown', k)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const url =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}${window.location.pathname}`
|
||||||
|
: ''
|
||||||
|
const mode = (transcript.share_mode ?? 'private') as ShareMode
|
||||||
|
const zulipEnabled = (config as { zulip_enabled?: boolean } | undefined)?.zulip_enabled
|
||||||
|
const emailEnabled = (config as { email_enabled?: boolean } | undefined)?.email_enabled
|
||||||
|
const canZulip = zulipEnabled && mode !== 'public'
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
toast.success('Link copied')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMode = async (next: ShareMode) => {
|
||||||
|
if (next === mode) return
|
||||||
|
setModeBusy(true)
|
||||||
|
try {
|
||||||
|
await onChangeShareMode(next)
|
||||||
|
} finally {
|
||||||
|
setModeBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmail = async () => {
|
||||||
|
if (!emailInput.trim()) return
|
||||||
|
setSendingEmail(true)
|
||||||
|
try {
|
||||||
|
await onSendEmail(emailInput.trim())
|
||||||
|
toast.success('Email sent')
|
||||||
|
setEmailInput('')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Email failed')
|
||||||
|
} finally {
|
||||||
|
setSendingEmail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZulip = async () => {
|
||||||
|
if (!stream.trim() || !topic.trim()) return
|
||||||
|
setPostingZulip(true)
|
||||||
|
try {
|
||||||
|
await onPostToZulip(stream.trim(), topic.trim())
|
||||||
|
toast.success('Posted to Zulip')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Zulip post failed')
|
||||||
|
} finally {
|
||||||
|
setPostingZulip(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ width: 'min(560px, calc(100vw - 32px))' }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '16px 20px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share transcript
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '2px 0 0',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MODE_LABEL[mode]} — {MODE_HINT[mode]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.X(16)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
maxHeight: 'calc(100vh - 180px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canEdit && (
|
||||||
|
<Section label="Privacy">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
gap: 0,
|
||||||
|
padding: 2,
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['private', 'semi-private', 'public'] as const).map((m) => {
|
||||||
|
const on = m === mode
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => handleMode(m)}
|
||||||
|
disabled={modeBusy}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: on ? 'var(--card)' : 'transparent',
|
||||||
|
color: on ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
boxShadow: on ? 'var(--shadow-xs)' : 'none',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: on ? 600 : 500,
|
||||||
|
cursor: modeBusy ? 'wait' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MODE_LABEL[m]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section label="Share link">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={url}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="rf-input"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11.5,
|
||||||
|
height: 34,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyUrl}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{I.Copy(13)} Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{emailEnabled && (
|
||||||
|
<Section label="Email">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="rf-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="person@example.com"
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => setEmailInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void handleEmail()
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, height: 34, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEmail}
|
||||||
|
disabled={sendingEmail || !emailInput.trim()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{sendingEmail ? 'Sending…' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canZulip && (
|
||||||
|
<Section label="Zulip">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr auto',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
value={stream}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStream(v)
|
||||||
|
setTopic('')
|
||||||
|
}}
|
||||||
|
options={zulipStreams.map((s) => s.name)}
|
||||||
|
placeholder="Stream"
|
||||||
|
inputStyle={{ height: 34, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
value={topic}
|
||||||
|
onChange={setTopic}
|
||||||
|
options={zulipTopics.map((t) => t.name)}
|
||||||
|
placeholder="Topic"
|
||||||
|
inputStyle={{ height: 34, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZulip}
|
||||||
|
disabled={postingZulip || !stream.trim() || !topic.trim()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{postingZulip ? 'Posting…' : 'Post'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog-wide boundary so any render failure inside the dialog body shows a
|
||||||
|
* graceful message and a Close button instead of white-screening the app.
|
||||||
|
*/
|
||||||
|
class DialogBoundary extends Component<
|
||||||
|
{ onClose: () => void; children: ReactNode },
|
||||||
|
{ error: Error | null }
|
||||||
|
> {
|
||||||
|
state: { error: Error | null } = { error: null }
|
||||||
|
static getDerivedStateFromError(err: Error) {
|
||||||
|
return { error: err }
|
||||||
|
}
|
||||||
|
componentDidCatch(err: unknown) {
|
||||||
|
console.error('ShareDialog crashed', err)
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
if (!this.state.error) return this.props.children
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rf-modal-backdrop" onClick={this.props.onClose} />
|
||||||
|
<div
|
||||||
|
className="rf-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ width: 'min(480px, calc(100vw - 32px))' }}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '16px 20px 12px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share — something went wrong
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 20,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: '0 0 10px' }}>
|
||||||
|
The Share dialog hit an error. Your link is:
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: 10,
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11.5,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}${window.location.pathname}`
|
||||||
|
: ''}
|
||||||
|
</code>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 0,
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="sm" onClick={this.props.onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
ui/src/components/transcript/StatusPlaceholder.tsx
Normal file
137
ui/src/components/transcript/StatusPlaceholder.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { ProgressRow } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
|
||||||
|
const FLAG_NOTE =
|
||||||
|
'New design pending for this flow. This placeholder keeps the route accessible while the pipeline finishes.'
|
||||||
|
|
||||||
|
export function StatusPlaceholder({ transcript }: { transcript: Transcript }) {
|
||||||
|
const kind = kindFor(transcript)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: 32,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{kind.icon}
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kind.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kind.body}
|
||||||
|
</p>
|
||||||
|
{kind.showProgress && <ProgressRow stage={kind.stage!} progress={null} />}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
paddingTop: 14,
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FLAG_NOTE}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindFor(t: Transcript) {
|
||||||
|
const status = t.status
|
||||||
|
if (status === 'recording' || (status === 'idle' && t.source_kind === 'live')) {
|
||||||
|
return {
|
||||||
|
icon: pulseDot(),
|
||||||
|
title: 'Live recording in progress',
|
||||||
|
body: 'This transcript is being captured live. The full detail view will appear once the session ends.',
|
||||||
|
showProgress: false as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 'idle' && t.source_kind === 'file') {
|
||||||
|
return {
|
||||||
|
icon: (
|
||||||
|
<span style={{ color: 'var(--fg-muted)', display: 'inline-flex' }}>
|
||||||
|
{I.FileAudio(22)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
title: 'Waiting for upload',
|
||||||
|
body: 'This transcript is pending an audio file. Upload from the transcript detail view on the legacy app, or trigger the upload flow from a new recording.',
|
||||||
|
showProgress: false as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 'uploaded' || status === 'processing') {
|
||||||
|
return {
|
||||||
|
icon: (
|
||||||
|
<span style={{ color: 'var(--status-processing)', display: 'inline-flex' }}>
|
||||||
|
{I.Loader(22)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
title: 'Processing the recording…',
|
||||||
|
body: 'The pipeline is transcribing, diarizing and summarizing. This page will update automatically when the transcript is ready.',
|
||||||
|
showProgress: true as const,
|
||||||
|
stage: status === 'uploaded' ? 'Uploaded' : 'Transcribing',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
icon: (
|
||||||
|
<span style={{ color: 'var(--fg-muted)', display: 'inline-flex' }}>
|
||||||
|
{I.Clock(22)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
title: 'Not ready',
|
||||||
|
body: 'This transcript is not in a viewable state yet.',
|
||||||
|
showProgress: false as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseDot() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: 'var(--status-live)',
|
||||||
|
animation: 'rfPulse 1.4s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
ui/src/components/transcript/SummaryPanel.tsx
Normal file
146
ui/src/components/transcript/SummaryPanel.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
import { Markdown } from '@/lib/markdown'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
summary: string | null | undefined
|
||||||
|
canEdit: boolean
|
||||||
|
saving: boolean
|
||||||
|
onSave: (next: string) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryPanel({ summary, canEdit, saving, onSave }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(summary ?? '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(summary ?? '')
|
||||||
|
}, [summary, editing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setEditing(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await onSave(draft)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</h2>
|
||||||
|
{canEdit && !editing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
title="Edit summary"
|
||||||
|
>
|
||||||
|
{I.Edit(14)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
void save()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 200,
|
||||||
|
padding: 12,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13.5,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
resize: 'vertical',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignSelf: 'center',
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Shift+Enter to save · Escape to cancel
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ color: 'var(--fg)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={save} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : summary?.trim() ? (
|
||||||
|
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 13.5 }}>
|
||||||
|
<Markdown source={summary} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 13, color: 'var(--fg-muted)', fontStyle: 'italic' }}
|
||||||
|
>
|
||||||
|
No summary available yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
277
ui/src/components/transcript/TopicsList.tsx
Normal file
277
ui/src/components/transcript/TopicsList.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { fmtDur } from '@/lib/format'
|
||||||
|
|
||||||
|
type Topic = components['schemas']['GetTranscriptTopic']
|
||||||
|
type Segment = components['schemas']['GetTranscriptSegmentTopic']
|
||||||
|
type Participant = components['schemas']['Participant']
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
topics: Topic[]
|
||||||
|
participants: Participant[]
|
||||||
|
activeTopicId: string | null
|
||||||
|
currentTime: number
|
||||||
|
onSeek: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicsList({
|
||||||
|
topics,
|
||||||
|
participants,
|
||||||
|
activeTopicId,
|
||||||
|
currentTime,
|
||||||
|
onSeek,
|
||||||
|
}: Props) {
|
||||||
|
if (topics.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No topics yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{topics.map((t, i) => (
|
||||||
|
<TopicItem
|
||||||
|
key={t.id ?? i}
|
||||||
|
topic={t}
|
||||||
|
participants={participants}
|
||||||
|
active={activeTopicId === t.id}
|
||||||
|
defaultExpanded={i === 0 || activeTopicId === t.id}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemProps = {
|
||||||
|
topic: Topic
|
||||||
|
participants: Participant[]
|
||||||
|
active: boolean
|
||||||
|
defaultExpanded: boolean
|
||||||
|
currentTime: number
|
||||||
|
onSeek: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopicItem({
|
||||||
|
topic,
|
||||||
|
participants,
|
||||||
|
active,
|
||||||
|
defaultExpanded,
|
||||||
|
currentTime,
|
||||||
|
onSeek,
|
||||||
|
}: ItemProps) {
|
||||||
|
const [open, setOpen] = useState(defaultExpanded)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Auto-scroll the active topic into view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && ref.current) {
|
||||||
|
ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
const segments: Segment[] = topic.segments ?? []
|
||||||
|
const started = topic.timestamp ?? 0
|
||||||
|
const end = started + (topic.duration ?? 0)
|
||||||
|
const inWindow = currentTime >= started && currentTime < end
|
||||||
|
|
||||||
|
const highlight = active || inWindow
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-active={highlight ? 'true' : undefined}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSeek(started)
|
||||||
|
setOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '14px 20px',
|
||||||
|
background: highlight ? 'var(--accent)' : 'var(--muted)',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
transition: 'background var(--dur-fast) var(--ease-default)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform var(--dur-fast)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronRight(14)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.005em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtTimestamp(started)}
|
||||||
|
{topic.duration && topic.duration > 0 ? ` · ${fmtDur(Math.floor(topic.duration))}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 20px 18px 46px',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13.5,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
background: 'var(--card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.summary?.trim() && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingLeft: 10,
|
||||||
|
borderLeft: '2px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{segments.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<TopicSegment
|
||||||
|
key={i}
|
||||||
|
segment={seg}
|
||||||
|
participants={participants}
|
||||||
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : topic.transcript?.trim() ? (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{topic.transcript}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 12 }}>No transcript.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopicSegment({
|
||||||
|
segment,
|
||||||
|
participants,
|
||||||
|
onSeek,
|
||||||
|
}: {
|
||||||
|
segment: Segment
|
||||||
|
participants: Participant[]
|
||||||
|
onSeek: (seconds: number) => void
|
||||||
|
}) {
|
||||||
|
const name = speakerNameFor(segment.speaker, participants)
|
||||||
|
const color = speakerColor(segment.speaker, Math.max(participants.length, 1))
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(segment.start)}
|
||||||
|
title="Seek to this moment"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
minWidth: 44,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtTimestamp(segment.start)}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>{segment.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerNameFor(speaker: number, participants: Participant[]): string {
|
||||||
|
const found = participants.find((p) => p.speaker === speaker)
|
||||||
|
return found?.name?.trim() || `Speaker ${speaker}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evenly distribute N speakers along an orange→green hue arc (passing
|
||||||
|
// through yellow/olive). The lightness alternates between two steps so
|
||||||
|
// adjacent speakers stay distinguishable even at high counts (20+ speakers):
|
||||||
|
// in a ~110° arc with 30 entries each hue step is ~3.5°, which is hard to
|
||||||
|
// read on its own — pairing it with a lightness flip effectively doubles the
|
||||||
|
// perceptual separation without breaking the tonal family.
|
||||||
|
function speakerColor(speaker: number, total: number): string {
|
||||||
|
const count = Math.max(total, 1)
|
||||||
|
const arcStart = 20 // orange
|
||||||
|
const arcEnd = 130 // green
|
||||||
|
const t = count === 1 ? 0.5 : (speaker % count) / (count - 1)
|
||||||
|
const hue = arcStart + t * (arcEnd - arcStart)
|
||||||
|
const lightness = speaker % 2 === 0 ? 40 : 48
|
||||||
|
return `hsl(${Math.round(hue)} 55% ${lightness}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimestamp(seconds: number | null | undefined): string {
|
||||||
|
if (!seconds || seconds < 0) return '00:00'
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (m < 60) return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}:${String(m % 60).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
206
ui/src/components/transcript/TranscriptHeader.tsx
Normal file
206
ui/src/components/transcript/TranscriptHeader.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button, RowMenuTrigger, StatusBadge } from '@/components/ui/primitives'
|
||||||
|
import type { TranscriptStatus as UiStatus } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
|
||||||
|
const API_TO_UI: Record<Transcript['status'], UiStatus> = {
|
||||||
|
idle: 'idle',
|
||||||
|
uploaded: 'uploading',
|
||||||
|
recording: 'live',
|
||||||
|
processing: 'processing',
|
||||||
|
error: 'failed',
|
||||||
|
ended: 'ended',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcript: Transcript
|
||||||
|
canEdit: boolean
|
||||||
|
canDownload: boolean
|
||||||
|
onRename: (next: string) => Promise<void> | void
|
||||||
|
onCopyMarkdown: () => void
|
||||||
|
onOpenShare: () => void
|
||||||
|
onDownloadZip: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onToggleVideo?: (() => void) | null
|
||||||
|
videoOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptHeader({
|
||||||
|
transcript,
|
||||||
|
canEdit,
|
||||||
|
canDownload,
|
||||||
|
onRename,
|
||||||
|
onCopyMarkdown,
|
||||||
|
onOpenShare,
|
||||||
|
onDownloadZip,
|
||||||
|
onDelete,
|
||||||
|
onToggleVideo,
|
||||||
|
videoOpen,
|
||||||
|
}: Props) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(titleFor(transcript))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(titleFor(transcript))
|
||||||
|
}, [transcript, editing])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
if (!canEdit) return
|
||||||
|
setDraft(titleFor(transcript))
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setDraft(titleFor(transcript))
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = async () => {
|
||||||
|
const next = draft.trim()
|
||||||
|
if (!next || next === titleFor(transcript)) {
|
||||||
|
setEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onRename(next)
|
||||||
|
setEditing(false)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--card)',
|
||||||
|
borderRadius: 'var(--radius-lg) var(--radius-lg) 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void commit()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => void commit()}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1
|
||||||
|
onClick={startEdit}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
cursor: canEdit ? 'text' : 'default',
|
||||||
|
}}
|
||||||
|
title={canEdit ? 'Click to rename' : undefined}
|
||||||
|
>
|
||||||
|
{titleFor(transcript)}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StatusBadge status={API_TO_UI[transcript.status]} />
|
||||||
|
|
||||||
|
{onToggleVideo && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleVideo}
|
||||||
|
title={videoOpen ? 'Hide video' : 'Show video'}
|
||||||
|
>
|
||||||
|
{I.FileAudio(13)} {videoOpen ? 'Hide video' : 'Video'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={onOpenShare} title="Share">
|
||||||
|
{I.Share(13)} Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<RowMenuTrigger
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Rename',
|
||||||
|
icon: I.Edit(14),
|
||||||
|
onClick: startEdit,
|
||||||
|
disabled: !canEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy as markdown',
|
||||||
|
icon: I.Copy(14),
|
||||||
|
onClick: onCopyMarkdown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Download ZIP',
|
||||||
|
icon: I.Download(14),
|
||||||
|
onClick: onDownloadZip,
|
||||||
|
disabled: !canDownload,
|
||||||
|
},
|
||||||
|
{ separator: true as const },
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: I.Trash(14),
|
||||||
|
danger: true,
|
||||||
|
disabled: !canEdit,
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label="Transcript options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFor(t: Transcript): string {
|
||||||
|
return t.title?.trim() || t.name?.trim() || 'Untitled transcript'
|
||||||
|
}
|
||||||
164
ui/src/components/transcript/VideoPanel.tsx
Normal file
164
ui/src/components/transcript/VideoPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
import { Button } from '@/components/ui/primitives'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
transcriptId: string
|
||||||
|
/** Whether the panel is shown at all. */
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal embed for the Daily composed video. The composed video is served
|
||||||
|
* through the backend under /v1/transcripts/{id}/video (auth required); we load
|
||||||
|
* it into a <video> tag via a blob URL so the Authorization header can be set.
|
||||||
|
*/
|
||||||
|
export function VideoPanel({ transcriptId, enabled }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !open) return
|
||||||
|
let cancelled = false
|
||||||
|
let url: string | null = null
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/v1/transcripts/${transcriptId}/video`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Video fetch failed (${res.status})`)
|
||||||
|
const blob = await res.blob()
|
||||||
|
if (cancelled) return
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
setBlobUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not load video')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (url) URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}, [enabled, open, transcriptId])
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
transform: open ? 'rotate(90deg)' : 'rotate(0)',
|
||||||
|
transition: 'transform var(--dur-fast)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronRight(14)}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>Video recording</span>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontSize: 12 }}>
|
||||||
|
{open ? 'Hide' : 'Show'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ padding: 16, paddingTop: 0 }}>
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
style={{ padding: 20, textAlign: 'center', color: 'var(--fg-muted)' }}
|
||||||
|
>
|
||||||
|
Loading video…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.AlertTriangle(14)} {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blobUrl && (
|
||||||
|
<video
|
||||||
|
src={blobUrl}
|
||||||
|
controls
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--gh-off-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* captions not wired yet */}
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
{I.ChevronLeft(12)} Collapse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
// Reuse the token lookup approach from the WS hook.
|
||||||
|
const pw = sessionStorage.getItem('reflector.password_token')
|
||||||
|
if (pw) return { Authorization: `Bearer ${pw}` }
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const k = sessionStorage.key(i)
|
||||||
|
if (!k?.startsWith('oidc.user:')) continue
|
||||||
|
const raw = sessionStorage.getItem(k)
|
||||||
|
if (!raw) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { access_token?: string }
|
||||||
|
if (parsed?.access_token) {
|
||||||
|
return { Authorization: `Bearer ${parsed.access_token}` }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
125
ui/src/components/transcript/WaveformCanvas.tsx
Normal file
125
ui/src/components/transcript/WaveformCanvas.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peaks: number[] | null | undefined
|
||||||
|
progress: number // 0..1 (played portion)
|
||||||
|
onSeek: (ratio: number) => void
|
||||||
|
/** In seconds; when provided, tick marks render at each position. */
|
||||||
|
ticks?: number[]
|
||||||
|
duration?: number
|
||||||
|
active?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight canvas-based waveform renderer. Scales to devicePixelRatio so the
|
||||||
|
* output stays crisp on high-DPI displays. Click anywhere to seek.
|
||||||
|
*/
|
||||||
|
export function WaveformCanvas({
|
||||||
|
peaks,
|
||||||
|
progress,
|
||||||
|
onSeek,
|
||||||
|
ticks,
|
||||||
|
duration,
|
||||||
|
active,
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null)
|
||||||
|
const normalized = useMemo(() => normalize(peaks), [peaks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvas.width = Math.max(1, Math.floor(rect.width * dpr))
|
||||||
|
canvas.height = Math.max(1, Math.floor(rect.height * dpr))
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
drawWaveform(ctx, rect.width, rect.height, normalized, progress)
|
||||||
|
if (ticks && duration && duration > 0) {
|
||||||
|
drawTicks(ctx, rect.width, rect.height, ticks, duration, active ?? null)
|
||||||
|
}
|
||||||
|
}, [normalized, progress, ticks, duration, active])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
onSeek(Math.max(0, Math.min(1, x / rect.width)))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas ref={ref} style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(peaks: number[] | null | undefined): number[] {
|
||||||
|
if (!peaks || peaks.length === 0) return []
|
||||||
|
const max = peaks.reduce((m, v) => Math.max(m, Math.abs(v)), 0) || 1
|
||||||
|
return peaks.map((v) => Math.abs(v) / max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveform(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
peaks: number[],
|
||||||
|
progress: number,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
if (peaks.length === 0) return
|
||||||
|
const mid = h / 2
|
||||||
|
const step = w / peaks.length
|
||||||
|
const barWidth = Math.max(1, Math.floor(step * 0.6))
|
||||||
|
const playedX = Math.max(0, Math.min(1, progress)) * w
|
||||||
|
for (let i = 0; i < peaks.length; i++) {
|
||||||
|
const x = Math.floor(i * step)
|
||||||
|
const amplitude = Math.max(2, peaks[i] * (h * 0.9))
|
||||||
|
const y = mid - amplitude / 2
|
||||||
|
const isPlayed = x < playedX
|
||||||
|
ctx.fillStyle = isPlayed ? 'var(--primary)' : 'var(--gh-grey-4)'
|
||||||
|
// Fallback for canvas (doesn't support var() directly).
|
||||||
|
ctx.fillStyle = isPlayed ? getCssVar('--primary') : getCssVar('--gh-grey-4')
|
||||||
|
ctx.fillRect(x, y, barWidth, amplitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTicks(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
ticks: number[],
|
||||||
|
duration: number,
|
||||||
|
active: number | null,
|
||||||
|
) {
|
||||||
|
for (const t of ticks) {
|
||||||
|
if (t < 0 || t > duration) continue
|
||||||
|
const x = (t / duration) * w
|
||||||
|
const isActive = active != null && Math.abs(active - t) < 0.01
|
||||||
|
ctx.strokeStyle = isActive ? getCssVar('--primary') : getCssVar('--fg')
|
||||||
|
ctx.globalAlpha = isActive ? 0.95 : 0.35
|
||||||
|
ctx.lineWidth = isActive ? 2 : 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, h)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCssVar(name: string): string {
|
||||||
|
if (typeof window === 'undefined') return '#000'
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
return v || '#000'
|
||||||
|
}
|
||||||
195
ui/src/components/ui/Combobox.tsx
Normal file
195
ui/src/components/ui/Combobox.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
options: string[]
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
inputStyle?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text input with a clickable dropdown of suggestions. Accepts free text so
|
||||||
|
* unknown values still round-trip. The listbox renders in a body-level portal
|
||||||
|
* with fixed positioning — otherwise it's clipped or scrolls its parent when
|
||||||
|
* used inside a dialog/overflow:hidden container.
|
||||||
|
*/
|
||||||
|
export function Combobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
inputStyle,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const update = () => {
|
||||||
|
const el = wrapRef.current
|
||||||
|
if (!el) return
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
setRect({ left: r.left, top: r.bottom + 4, width: r.width })
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('scroll', update, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('scroll', update, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (wrapRef.current?.contains(target)) return
|
||||||
|
if (listRef.current?.contains(target)) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const filtered = value
|
||||||
|
? options.filter((o) => o.toLowerCase().includes(value.toLowerCase()))
|
||||||
|
: options
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<div style={{ position: 'relative', display: 'flex' }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="rf-input"
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
if (!open) setOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 30,
|
||||||
|
minWidth: 0,
|
||||||
|
...(inputStyle ?? {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (disabled) return
|
||||||
|
setOpen((v) => !v)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Toggle suggestions"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.ChevronDown(12)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && rect &&
|
||||||
|
createPortal(
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
margin: 0,
|
||||||
|
padding: 4,
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
listStyle: 'none',
|
||||||
|
maxHeight: 240,
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 9999,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.length === 0 ? 'No options available' : 'No matches'}
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((o) => (
|
||||||
|
<li
|
||||||
|
key={o}
|
||||||
|
role="option"
|
||||||
|
aria-selected={o === value}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(o)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
background: o === value ? 'var(--muted)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--muted)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background =
|
||||||
|
o === value ? 'var(--muted)' : 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
588
ui/src/components/ui/primitives.tsx
Normal file
588
ui/src/components/ui/primitives.tsx
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
} from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { I } from '@/components/icons'
|
||||||
|
|
||||||
|
export type TranscriptStatus = 'live' | 'ended' | 'processing' | 'uploading' | 'failed' | 'idle'
|
||||||
|
|
||||||
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||||
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'icon' | 'iconSm'
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
size?: ButtonSize
|
||||||
|
ref?: Ref<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
ref,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base: CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid transparent',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
transition: 'all var(--dur-normal) var(--ease-default)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}
|
||||||
|
const sizes: Record<ButtonSize, CSSProperties> = {
|
||||||
|
xs: { height: 26, padding: '0 8px', fontSize: 12 },
|
||||||
|
sm: { height: 30, padding: '0 10px', fontSize: 13 },
|
||||||
|
md: { height: 36, padding: '0 14px', fontSize: 14 },
|
||||||
|
icon: { height: 32, width: 32, padding: 0 },
|
||||||
|
iconSm: { height: 28, width: 28, padding: 0 },
|
||||||
|
}
|
||||||
|
const variants: Record<ButtonVariant, CSSProperties> = {
|
||||||
|
primary: { background: 'var(--primary)', color: 'var(--primary-fg)', boxShadow: 'var(--shadow-xs)' },
|
||||||
|
secondary: { background: 'var(--secondary)', color: 'var(--secondary-fg)', borderColor: 'var(--border)' },
|
||||||
|
outline: { background: 'var(--card)', color: 'var(--fg)', borderColor: 'var(--border)', boxShadow: 'var(--shadow-xs)' },
|
||||||
|
ghost: { background: 'transparent', color: 'var(--fg-muted)' },
|
||||||
|
danger: { background: 'transparent', color: 'var(--destructive)' },
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
style={{ ...base, ...sizes[size], ...variants[variant], ...style }}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusDot({ status, size = 8 }: { status: TranscriptStatus; size?: number }) {
|
||||||
|
const map: Record<TranscriptStatus, string> = {
|
||||||
|
live: 'var(--status-live)',
|
||||||
|
ended: 'var(--status-ok)',
|
||||||
|
processing: 'var(--status-processing)',
|
||||||
|
uploading: 'var(--status-processing)',
|
||||||
|
failed: 'var(--status-failed)',
|
||||||
|
idle: 'var(--status-idle)',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: map[status] ?? map.idle,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeStyle = { color: string; bg: string; bd: string }
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: TranscriptStatus }) {
|
||||||
|
const labels: Record<TranscriptStatus, string> = {
|
||||||
|
live: 'Live',
|
||||||
|
ended: 'Done',
|
||||||
|
processing: 'Processing',
|
||||||
|
uploading: 'Uploading',
|
||||||
|
failed: 'Failed',
|
||||||
|
idle: 'Idle',
|
||||||
|
}
|
||||||
|
const styles: Partial<Record<TranscriptStatus, BadgeStyle>> = {
|
||||||
|
live: { color: 'var(--status-live)', bg: 'rgba(217,94,42,0.08)', bd: 'rgba(217,94,42,0.25)' },
|
||||||
|
processing: {
|
||||||
|
color: 'var(--status-processing)',
|
||||||
|
bg: 'color-mix(in oklch, var(--status-processing) 10%, transparent)',
|
||||||
|
bd: 'color-mix(in oklch, var(--status-processing) 30%, transparent)',
|
||||||
|
},
|
||||||
|
uploading: {
|
||||||
|
color: 'var(--status-processing)',
|
||||||
|
bg: 'color-mix(in oklch, var(--status-processing) 10%, transparent)',
|
||||||
|
bd: 'color-mix(in oklch, var(--status-processing) 30%, transparent)',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
bg: 'color-mix(in oklch, var(--destructive) 10%, transparent)',
|
||||||
|
bd: 'color-mix(in oklch, var(--destructive) 25%, transparent)',
|
||||||
|
},
|
||||||
|
ended: { color: 'var(--fg-muted)', bg: 'var(--muted)', bd: 'var(--border)' },
|
||||||
|
}
|
||||||
|
const s = styles[status] ?? styles.ended!
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '1px 8px',
|
||||||
|
height: 20,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: s.color,
|
||||||
|
background: s.bg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: s.bd,
|
||||||
|
borderRadius: 9999,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusDot status={status} size={6} />
|
||||||
|
{labels[status] ?? status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Waveform({
|
||||||
|
seed = 1,
|
||||||
|
bars = 22,
|
||||||
|
color = 'var(--fg-muted)',
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
seed?: number
|
||||||
|
bars?: number
|
||||||
|
color?: string
|
||||||
|
active?: boolean
|
||||||
|
}) {
|
||||||
|
const heights = useMemo(() => {
|
||||||
|
const out: number[] = []
|
||||||
|
let s = seed * 9301
|
||||||
|
for (let i = 0; i < bars; i++) {
|
||||||
|
s = (s * 9301 + 49297) % 233280
|
||||||
|
const r = s / 233280
|
||||||
|
const env = 0.35 + 0.65 * Math.sin((i / bars) * Math.PI)
|
||||||
|
out.push(Math.max(3, Math.round(env * 24 * (0.4 + r * 0.9))))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [seed, bars])
|
||||||
|
return (
|
||||||
|
<div className="rf-wave" style={{ color, opacity: active ? 1 : 0.75 }}>
|
||||||
|
{heights.map((h, i) => (
|
||||||
|
<span key={i} style={{ height: h, opacity: active && i < bars * 0.6 ? 1 : undefined }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tag({ children, onRemove }: { children: ReactNode; onRemove?: () => void }) {
|
||||||
|
return (
|
||||||
|
<span className="rf-tag">
|
||||||
|
{children}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.Close(10)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
dot,
|
||||||
|
kbd,
|
||||||
|
indent = false,
|
||||||
|
}: {
|
||||||
|
icon?: ReactNode
|
||||||
|
label: ReactNode
|
||||||
|
count?: number | null
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
dot?: string
|
||||||
|
kbd?: string
|
||||||
|
indent?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
padding: indent ? '6px 10px 6px 30px' : '7px 10px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
color: active ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
background: active ? 'var(--card)' : 'transparent',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? 'var(--border)' : 'transparent',
|
||||||
|
boxShadow: active ? 'var(--shadow-xs)' : 'none',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: -11,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
width: 2,
|
||||||
|
background: 'var(--primary)',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{icon && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
color: active ? 'var(--primary)' : 'var(--fg-muted)',
|
||||||
|
opacity: active ? 1 : 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{dot && <span style={{ width: 6, height: 6, borderRadius: 9999, background: dot }} />}
|
||||||
|
{count != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: active ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kbd && count == null && <span className="rf-kbd">{kbd}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionLabel({ children, action }: { children: ReactNode; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0 10px 6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressRow({
|
||||||
|
stage,
|
||||||
|
progress,
|
||||||
|
eta,
|
||||||
|
}: {
|
||||||
|
stage: string
|
||||||
|
progress?: number | null
|
||||||
|
eta?: string | null
|
||||||
|
}) {
|
||||||
|
const pct = Math.round((progress ?? 0) * 100)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '6px 10px',
|
||||||
|
marginTop: 2,
|
||||||
|
background: 'color-mix(in oklch, var(--status-processing) 6%, var(--card))',
|
||||||
|
border: '1px solid color-mix(in oklch, var(--status-processing) 22%, transparent)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 11.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rf-spinner"
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 9999,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '2px solid color-mix(in oklch, var(--status-processing) 25%, transparent)',
|
||||||
|
borderTopColor: 'var(--status-processing)',
|
||||||
|
animation: 'rfSpin 0.9s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--status-processing)', fontWeight: 600 }}>{stage}…</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 4,
|
||||||
|
background: 'color-mix(in oklch, var(--status-processing) 15%, transparent)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--status-processing)',
|
||||||
|
transition: 'width 400ms var(--ease-default)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--status-processing)',
|
||||||
|
minWidth: 32,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
{eta && (
|
||||||
|
<span style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{eta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RowMenuItem =
|
||||||
|
| { separator: true }
|
||||||
|
| {
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
kbd?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowMenuProps = {
|
||||||
|
items?: RowMenuItem[]
|
||||||
|
onClose?: () => void
|
||||||
|
/** Bounding rect of the trigger button; used to position the floating menu. */
|
||||||
|
anchor?: DOMRect | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_WIDTH = 200
|
||||||
|
const MENU_GAP = 4
|
||||||
|
|
||||||
|
export function RowMenu({ items = [], onClose, anchor }: RowMenuProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number }>(() =>
|
||||||
|
computePos(anchor, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const height = ref.current?.offsetHeight ?? 0
|
||||||
|
setPos(computePos(anchor, height))
|
||||||
|
}, [anchor, items.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose?.()
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose?.()
|
||||||
|
}
|
||||||
|
const onScrollOrResize = () => onClose?.()
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.addEventListener('resize', onScrollOrResize)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="menu"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
minWidth: MENU_WIDTH,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
padding: 4,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((it, i) => {
|
||||||
|
if ('separator' in it) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ height: 1, background: 'var(--border)', margin: '4px 2px' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const danger = it.danger
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
role="menuitem"
|
||||||
|
disabled={it.disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
it.onClick?.()
|
||||||
|
onClose?.()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
padding: '7px 10px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: it.disabled
|
||||||
|
? 'var(--fg-muted)'
|
||||||
|
: danger
|
||||||
|
? 'var(--destructive)'
|
||||||
|
: 'var(--fg)',
|
||||||
|
opacity: it.disabled ? 0.5 : 1,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: it.disabled ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!it.disabled) {
|
||||||
|
e.currentTarget.style.background = danger
|
||||||
|
? 'color-mix(in oklch, var(--destructive) 10%, transparent)'
|
||||||
|
: 'var(--muted)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
color: danger ? 'var(--destructive)' : 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>{it.label}</span>
|
||||||
|
{it.kbd && (
|
||||||
|
<span className="rf-kbd" style={{ fontSize: 10 }}>
|
||||||
|
{it.kbd}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePos(anchor: DOMRect | null | undefined, menuHeight: number) {
|
||||||
|
if (!anchor) return { top: 0, left: 0 }
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const vw = window.innerWidth
|
||||||
|
let top = anchor.bottom + MENU_GAP
|
||||||
|
if (menuHeight > 0 && top + menuHeight > vh - 8) {
|
||||||
|
// Flip above the trigger when there's no room below.
|
||||||
|
top = Math.max(8, anchor.top - MENU_GAP - menuHeight)
|
||||||
|
}
|
||||||
|
let left = anchor.right - MENU_WIDTH
|
||||||
|
if (left < 8) left = 8
|
||||||
|
if (left + MENU_WIDTH > vw - 8) left = vw - MENU_WIDTH - 8
|
||||||
|
return { top, left }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowMenuTrigger({
|
||||||
|
items,
|
||||||
|
label = 'Options',
|
||||||
|
}: {
|
||||||
|
items: RowMenuItem[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [anchor, setAnchor] = useState<DOMRect | null>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex' }}>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
title={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setAnchor(triggerRef.current?.getBoundingClientRect() ?? null)
|
||||||
|
setOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.More(16)}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<RowMenu items={items} anchor={anchor} onClose={() => setOpen(false)} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
ui/src/hooks/useRooms.ts
Normal file
42
ui/src/hooks/useRooms.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { RoomRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
type ServerRoom = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
is_shared?: boolean
|
||||||
|
shared?: boolean
|
||||||
|
transcripts_count?: number | null
|
||||||
|
count?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(r: ServerRoom): RoomRowData {
|
||||||
|
const rawCount = r.transcripts_count ?? r.count
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
shared: r.is_shared ?? r.shared ?? false,
|
||||||
|
// Backend doesn't expose a per-room transcript count today, so leave it
|
||||||
|
// null unless the response happens to include one — consumers render
|
||||||
|
// `null` as "no badge".
|
||||||
|
count: typeof rawCount === 'number' ? rawCount : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRooms() {
|
||||||
|
return useQuery<RoomRowData[]>({
|
||||||
|
queryKey: ['rooms'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET('/v1/rooms', {
|
||||||
|
params: { query: { page: 1, size: 100 } as never },
|
||||||
|
})
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Failed to load rooms'), { status: response.status })
|
||||||
|
}
|
||||||
|
const page = data as { items?: ServerRoom[] }
|
||||||
|
return (page.items ?? []).map(normalize)
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
235
ui/src/hooks/useTranscript.ts
Normal file
235
ui/src/hooks/useTranscript.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { extractDetail } from '@/lib/apiErrors'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
type Topic = components['schemas']['GetTranscriptTopic']
|
||||||
|
type Participant = components['schemas']['Participant']
|
||||||
|
type Waveform = components['schemas']['AudioWaveform']
|
||||||
|
|
||||||
|
const POLL_STATUSES = new Set(['processing', 'uploaded', 'recording'])
|
||||||
|
|
||||||
|
export const transcriptKey = (id: string) => ['transcript', id] as const
|
||||||
|
export const topicsKey = (id: string) => ['transcript', id, 'topics'] as const
|
||||||
|
export const waveformKey = (id: string) => ['transcript', id, 'waveform'] as const
|
||||||
|
export const participantsKey = (id: string) =>
|
||||||
|
['transcript', id, 'participants'] as const
|
||||||
|
|
||||||
|
export function useTranscript(id: string | undefined) {
|
||||||
|
return useQuery<Transcript>({
|
||||||
|
queryKey: id ? transcriptKey(id) : ['transcript', 'none'],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response, error } = await apiClient.GET(
|
||||||
|
'/v1/transcripts/{transcript_id}',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Failed to load transcript'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data as Transcript
|
||||||
|
},
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const status = (q.state.data as Transcript | undefined)?.status
|
||||||
|
return status && POLL_STATUSES.has(status) ? 5_000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscriptTopics(id: string | undefined, enabled = true) {
|
||||||
|
return useQuery<Topic[]>({
|
||||||
|
queryKey: id ? topicsKey(id) : ['transcript', 'none', 'topics'],
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET(
|
||||||
|
'/v1/transcripts/{transcript_id}/topics',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Failed to load topics'), {
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data as Topic[]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscriptWaveform(id: string | undefined, enabled: boolean) {
|
||||||
|
return useQuery<Waveform>({
|
||||||
|
queryKey: id ? waveformKey(id) : ['transcript', 'none', 'waveform'],
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET(
|
||||||
|
'/v1/transcripts/{transcript_id}/audio/waveform',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Failed to load waveform'), {
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data as Waveform
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscriptParticipants(id: string | undefined, enabled = true) {
|
||||||
|
return useQuery<Participant[]>({
|
||||||
|
queryKey: id ? participantsKey(id) : ['transcript', 'none', 'participants'],
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await apiClient.GET(
|
||||||
|
'/v1/transcripts/{transcript_id}/participants',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Failed to load participants'), {
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data as Participant[]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateBody = components['schemas']['UpdateTranscript']
|
||||||
|
|
||||||
|
export function useTranscriptMutations(id: string | undefined) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
if (!id) return
|
||||||
|
queryClient.invalidateQueries({ queryKey: transcriptKey(id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async (patch: UpdateBody) => {
|
||||||
|
const { data, response, error } = await apiClient.PATCH(
|
||||||
|
'/v1/transcripts/{transcript_id}',
|
||||||
|
{
|
||||||
|
params: { path: { transcript_id: id! } },
|
||||||
|
body: patch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Update failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const softDelete = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { response, error } = await apiClient.DELETE(
|
||||||
|
'/v1/transcripts/{transcript_id}',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Delete failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['transcripts'] })
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const restore = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { response, error } = await apiClient.POST(
|
||||||
|
'/v1/transcripts/{transcript_id}/restore',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Restore failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['transcripts'] })
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const destroy = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { response, error } = await apiClient.DELETE(
|
||||||
|
'/v1/transcripts/{transcript_id}/destroy',
|
||||||
|
{ params: { path: { transcript_id: id! } } },
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Destroy failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['transcripts'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendEmail = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
const { data, response, error } = await apiClient.POST(
|
||||||
|
'/v1/transcripts/{transcript_id}/email',
|
||||||
|
{
|
||||||
|
params: { path: { transcript_id: id! } },
|
||||||
|
body: { email } as never,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Email failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const postToZulip = useMutation({
|
||||||
|
mutationFn: async (args: {
|
||||||
|
stream: string
|
||||||
|
topic: string
|
||||||
|
include_topics?: boolean
|
||||||
|
}) => {
|
||||||
|
const { response, error } = await apiClient.POST(
|
||||||
|
'/v1/transcripts/{transcript_id}/zulip',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { transcript_id: id! },
|
||||||
|
query: {
|
||||||
|
stream: args.stream,
|
||||||
|
topic: args.topic,
|
||||||
|
include_topics: args.include_topics ?? true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Zulip post failed'), {
|
||||||
|
status: response.status,
|
||||||
|
detail: extractDetail(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { update, softDelete, restore, destroy, sendEmail, postToZulip }
|
||||||
|
}
|
||||||
217
ui/src/hooks/useTranscriptWs.ts
Normal file
217
ui/src/hooks/useTranscriptWs.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { PASSWORD_TOKEN_KEY } from '@/api/client'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
import {
|
||||||
|
participantsKey,
|
||||||
|
topicsKey,
|
||||||
|
transcriptKey,
|
||||||
|
waveformKey,
|
||||||
|
} from './useTranscript'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
type Topic = components['schemas']['GetTranscriptTopic']
|
||||||
|
|
||||||
|
const MAX_RETRIES = 10
|
||||||
|
|
||||||
|
function getReconnectDelayMs(retryIndex: number) {
|
||||||
|
return Math.min(1000 * Math.pow(2, retryIndex), 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(PASSWORD_TOKEN_KEY)
|
||||||
|
if (stored) return stored
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// OIDC store keys look like oidc.user:<authority>:<client_id>
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const k = sessionStorage.key(i)
|
||||||
|
if (!k || !k.startsWith('oidc.user:')) continue
|
||||||
|
const raw = sessionStorage.getItem(k)
|
||||||
|
if (!raw) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { access_token?: string }
|
||||||
|
if (parsed?.access_token) return parsed.access_token
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveHandler = (text: string, translation: string) => void
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
onLiveText?: LiveHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscriptWs(id: string | undefined, opts: Options = {}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
|
const retryRef = useRef(0)
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const onLiveRef = useRef(opts.onLiveText)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLiveRef.current = opts.onLiveText
|
||||||
|
}, [opts.onLiveText])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
aliveRef.current = true
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (!aliveRef.current) return
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const url = `${proto}//${window.location.host}/v1/transcripts/${id}/events`
|
||||||
|
const token = getToken()
|
||||||
|
const subprotocols: string[] = ['bearer']
|
||||||
|
if (token) subprotocols.push(token)
|
||||||
|
let ws: WebSocket
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url, subprotocols)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WS construct failed', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socketRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
retryRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: { event?: string; data?: unknown }
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data as string)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!msg?.event) return
|
||||||
|
dispatch(msg as { event: string; data: unknown })
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// error handled by onclose retry
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
socketRef.current = null
|
||||||
|
if (!aliveRef.current) return
|
||||||
|
if (retryRef.current >= MAX_RETRIES) return
|
||||||
|
const delay = getReconnectDelayMs(retryRef.current)
|
||||||
|
retryRef.current += 1
|
||||||
|
setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = ({ event, data }: { event: string; data: unknown }) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'STATUS': {
|
||||||
|
const next = (data as { value?: Transcript['status'] })?.value
|
||||||
|
if (next) {
|
||||||
|
queryClient.setQueryData<Transcript | undefined>(
|
||||||
|
transcriptKey(id),
|
||||||
|
(prev) => (prev ? { ...prev, status: next } : prev),
|
||||||
|
)
|
||||||
|
queryClient.invalidateQueries({ queryKey: transcriptKey(id) })
|
||||||
|
if (next === 'ended' || next === 'error') {
|
||||||
|
queryClient.invalidateQueries({ queryKey: topicsKey(id) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: waveformKey(id) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: participantsKey(id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'FINAL_TITLE': {
|
||||||
|
const title = (data as { title?: string })?.title
|
||||||
|
if (typeof title !== 'string') return
|
||||||
|
// Skip replay on terminal transcripts — the GET response is the
|
||||||
|
// source of truth (includes user edits). Only apply during the
|
||||||
|
// processing → ended transition.
|
||||||
|
const current = queryClient.getQueryData<Transcript>(transcriptKey(id))
|
||||||
|
const status = current?.status
|
||||||
|
if (status === 'ended' || status === 'error') return
|
||||||
|
queryClient.setQueryData<Transcript | undefined>(
|
||||||
|
transcriptKey(id),
|
||||||
|
(prev) => (prev ? { ...prev, title } : prev),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'FINAL_LONG_SUMMARY': {
|
||||||
|
const long_summary = (data as { long_summary?: string })?.long_summary
|
||||||
|
if (typeof long_summary !== 'string') return
|
||||||
|
const current = queryClient.getQueryData<Transcript>(transcriptKey(id))
|
||||||
|
const status = current?.status
|
||||||
|
if (status === 'ended' || status === 'error') return
|
||||||
|
queryClient.setQueryData<Transcript | undefined>(
|
||||||
|
transcriptKey(id),
|
||||||
|
(prev) => (prev ? { ...prev, long_summary } : prev),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'DURATION': {
|
||||||
|
const duration = (data as { duration?: number })?.duration
|
||||||
|
if (typeof duration === 'number') {
|
||||||
|
queryClient.setQueryData<Transcript | undefined>(
|
||||||
|
transcriptKey(id),
|
||||||
|
(prev) => (prev ? { ...prev, duration } : prev),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'WAVEFORM': {
|
||||||
|
const waveform = (data as { waveform?: number[] })?.waveform
|
||||||
|
if (Array.isArray(waveform)) {
|
||||||
|
queryClient.setQueryData(waveformKey(id), { data: waveform })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'TOPIC': {
|
||||||
|
const topic = data as Topic
|
||||||
|
queryClient.setQueryData<Topic[] | undefined>(
|
||||||
|
topicsKey(id),
|
||||||
|
(prev) => {
|
||||||
|
if (!prev) return [topic]
|
||||||
|
const existing = prev.findIndex((x) => x.id === topic.id)
|
||||||
|
if (existing >= 0) {
|
||||||
|
const next = prev.slice()
|
||||||
|
next[existing] = topic
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return [...prev, topic]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Ensure we reconcile with server ordering (the backend replays
|
||||||
|
// stored TOPIC events on WS connect — dedupe alone isn't enough
|
||||||
|
// if the server emits refined titles later).
|
||||||
|
queryClient.invalidateQueries({ queryKey: topicsKey(id) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'TRANSCRIPT': {
|
||||||
|
const text = (data as { text?: string })?.text ?? ''
|
||||||
|
const translation = (data as { translation?: string })?.translation ?? ''
|
||||||
|
onLiveRef.current?.(text, translation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
const ws = socketRef.current
|
||||||
|
socketRef.current = null
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.close()
|
||||||
|
}
|
||||||
|
}, [id, queryClient])
|
||||||
|
}
|
||||||
174
ui/src/hooks/useTranscripts.ts
Normal file
174
ui/src/hooks/useTranscripts.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { components, paths } from '@/api/schema'
|
||||||
|
import type { TranscriptRowData, TranscriptSource, TranscriptStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
type ApiStatus = components['schemas']['SearchResult']['status']
|
||||||
|
|
||||||
|
const STATUS_TO_ROW: Record<ApiStatus, TranscriptStatus> = {
|
||||||
|
idle: 'idle',
|
||||||
|
uploaded: 'uploading',
|
||||||
|
recording: 'live',
|
||||||
|
processing: 'processing',
|
||||||
|
error: 'failed',
|
||||||
|
ended: 'ended',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceKind = components['schemas']['SourceKind']
|
||||||
|
|
||||||
|
function mapSource(kind: SourceKind): TranscriptSource {
|
||||||
|
if (kind === 'file') return 'upload'
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeLang(src?: string | null, tgt?: string | null): string {
|
||||||
|
if (src && tgt && src !== tgt) return `${src}→${tgt}`
|
||||||
|
return src ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippetOf(snippets?: string[] | null): string | null {
|
||||||
|
if (!snippets || snippets.length === 0) return null
|
||||||
|
return snippets[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend stores duration in milliseconds (see server/.../file_pipeline.py: `{"duration": duration_ms}`),
|
||||||
|
// despite SearchResult's schema description saying "seconds". Normalize to whole seconds here.
|
||||||
|
function toSeconds(ms: number | null | undefined): number {
|
||||||
|
if (!ms) return 0
|
||||||
|
return Math.round(ms / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchResult(r: components['schemas']['SearchResult']): TranscriptRowData {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title ?? '',
|
||||||
|
status: STATUS_TO_ROW[r.status],
|
||||||
|
source: mapSource(r.source_kind),
|
||||||
|
room: r.room_name ?? null,
|
||||||
|
date: r.created_at,
|
||||||
|
duration: toSeconds(r.duration),
|
||||||
|
speakers: r.speaker_count ?? 0,
|
||||||
|
lang: '',
|
||||||
|
tags: [],
|
||||||
|
snippet: snippetOf(r.search_snippets),
|
||||||
|
error_message: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListItem(r: components['schemas']['GetTranscriptMinimal']): TranscriptRowData {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title ?? r.name ?? '',
|
||||||
|
status: STATUS_TO_ROW[r.status],
|
||||||
|
source: mapSource(r.source_kind),
|
||||||
|
room: r.room_name ?? null,
|
||||||
|
date: r.created_at,
|
||||||
|
duration: toSeconds(r.duration),
|
||||||
|
speakers: r.speaker_count ?? 0,
|
||||||
|
lang: composeLang(r.source_language, r.target_language),
|
||||||
|
tags: [],
|
||||||
|
snippet: null,
|
||||||
|
error_message: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchParams = NonNullable<paths['/v1/transcripts/search']['get']['parameters']['query']>
|
||||||
|
type ListParams = NonNullable<paths['/v1/transcripts']['get']['parameters']['query']>
|
||||||
|
|
||||||
|
export type TranscriptListResult = {
|
||||||
|
items: TranscriptRowData[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranscriptSort = 'newest' | 'oldest' | 'longest'
|
||||||
|
|
||||||
|
type UseTranscriptsArgs = {
|
||||||
|
query: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
sourceKind?: 'live' | 'file' | 'room'
|
||||||
|
roomId?: string | null
|
||||||
|
includeDeleted?: boolean
|
||||||
|
/** Keep only transcripts whose created_at is within this many days. */
|
||||||
|
sinceDays?: number | null
|
||||||
|
sort?: TranscriptSort
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortItems(items: TranscriptRowData[], sort: TranscriptSort): TranscriptRowData[] {
|
||||||
|
const out = [...items]
|
||||||
|
if (sort === 'oldest') out.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
else if (sort === 'longest') out.sort((a, b) => b.duration - a.duration)
|
||||||
|
else out.sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscripts({
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sourceKind,
|
||||||
|
roomId,
|
||||||
|
includeDeleted,
|
||||||
|
sinceDays,
|
||||||
|
sort = 'newest',
|
||||||
|
}: UseTranscriptsArgs) {
|
||||||
|
const q = query.trim()
|
||||||
|
const sinceIso =
|
||||||
|
sinceDays && sinceDays > 0
|
||||||
|
? new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
: null
|
||||||
|
|
||||||
|
const useSearchEndpoint = q.length > 0 || !!includeDeleted
|
||||||
|
|
||||||
|
return useQuery<TranscriptListResult>({
|
||||||
|
queryKey: [
|
||||||
|
'transcripts',
|
||||||
|
{ q, page, pageSize, sourceKind, roomId, includeDeleted, sinceIso, sort },
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (useSearchEndpoint) {
|
||||||
|
const params: SearchParams = {
|
||||||
|
q,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
}
|
||||||
|
if (sourceKind) params.source_kind = sourceKind
|
||||||
|
if (roomId) params.room_id = roomId
|
||||||
|
if (includeDeleted) params.include_deleted = true
|
||||||
|
if (sinceIso) params.from = sinceIso
|
||||||
|
const { data, response } = await apiClient.GET('/v1/transcripts/search', {
|
||||||
|
params: { query: params },
|
||||||
|
})
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('Search failed'), { status: response.status })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: sortItems(data.results.map(normalizeSearchResult), sort),
|
||||||
|
total: data.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const params: ListParams = {
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
sort_by: 'created_at',
|
||||||
|
}
|
||||||
|
if (sourceKind) params.source_kind = sourceKind
|
||||||
|
if (roomId) params.room_id = roomId
|
||||||
|
const { data, response } = await apiClient.GET('/v1/transcripts', {
|
||||||
|
params: { query: params },
|
||||||
|
})
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
throw Object.assign(new Error('List failed'), { status: response.status })
|
||||||
|
}
|
||||||
|
const allItems = data.items.map(normalizeListItem)
|
||||||
|
const filtered = sinceIso
|
||||||
|
? allItems.filter((t) => t.date >= sinceIso)
|
||||||
|
: allItems
|
||||||
|
return {
|
||||||
|
items: sortItems(filtered, sort),
|
||||||
|
total: sinceIso ? filtered.length : (data.total ?? allItems.length),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
}
|
||||||
16
ui/src/lib/apiErrors.ts
Normal file
16
ui/src/lib/apiErrors.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function extractDetail(error: unknown): string | null {
|
||||||
|
if (error && typeof error === 'object' && 'detail' in error) {
|
||||||
|
const d = (error as { detail?: unknown }).detail
|
||||||
|
if (typeof d === 'string') return d
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageFor(err: unknown, fallback: string): string {
|
||||||
|
if (err && typeof err === 'object' && 'detail' in err) {
|
||||||
|
const d = (err as { detail?: unknown }).detail
|
||||||
|
if (typeof d === 'string') return d
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
7
ui/src/lib/env.ts
Normal file
7
ui/src/lib/env.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const env = {
|
||||||
|
oidcAuthority: import.meta.env.VITE_OIDC_AUTHORITY ?? '',
|
||||||
|
oidcClientId: import.meta.env.VITE_OIDC_CLIENT_ID ?? '',
|
||||||
|
oidcScope: import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const oidcEnabled = Boolean(env.oidcAuthority && env.oidcClientId)
|
||||||
25
ui/src/lib/format.ts
Normal file
25
ui/src/lib/format.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export function fmtDur(s: number | null | undefined): string {
|
||||||
|
if (!s) return '—'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const ss = String(Math.floor(s % 60)).padStart(2, '0')
|
||||||
|
if (m < 60) return `${m}:${ss}`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
const mm = String(m % 60).padStart(2, '0')
|
||||||
|
return `${h}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||||
|
|
||||||
|
export function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
let d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime()) && iso.includes(' ')) {
|
||||||
|
d = new Date(iso.replace(' ', 'T'))
|
||||||
|
}
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const month = MONTHS[d.getMonth()]
|
||||||
|
const day = d.getDate()
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${month} ${day}, ${hh}:${mm}`
|
||||||
|
}
|
||||||
195
ui/src/lib/markdown.tsx
Normal file
195
ui/src/lib/markdown.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Fragment, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal block/inline markdown renderer for transcript summaries.
|
||||||
|
* Supports: #..###### headings, blank-line paragraph breaks, - bulleted lists,
|
||||||
|
* 1. numbered lists, `code` inline, **bold**, *italic*, [text](url), newlines → <br>.
|
||||||
|
* NOT a full CommonMark parser. Keep summaries sane; anything fancier renders as text.
|
||||||
|
*/
|
||||||
|
export function Markdown({ source }: { source: string | null | undefined }) {
|
||||||
|
if (!source) return null
|
||||||
|
const blocks = splitBlocks(source)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((block, i) => (
|
||||||
|
<Fragment key={i}>{renderBlock(block)}</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: 'heading'; level: number; text: string }
|
||||||
|
| { kind: 'paragraph'; text: string }
|
||||||
|
| { kind: 'ul'; items: string[] }
|
||||||
|
| { kind: 'ol'; items: string[] }
|
||||||
|
|
||||||
|
function splitBlocks(src: string): Block[] {
|
||||||
|
const lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||||
|
const out: Block[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line.trim()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const heading = line.match(/^(#{1,6})\s+(.*)$/)
|
||||||
|
if (heading) {
|
||||||
|
out.push({ kind: 'heading', level: heading[1].length, text: heading[2] })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/^\s*[-*+]\s+/.test(line)) {
|
||||||
|
const items: string[] = []
|
||||||
|
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.push({ kind: 'ul', items })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/^\s*\d+\.\s+/.test(line)) {
|
||||||
|
const items: string[] = []
|
||||||
|
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.push({ kind: 'ol', items })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Paragraph: collect until blank line / heading / list
|
||||||
|
const buf: string[] = []
|
||||||
|
while (
|
||||||
|
i < lines.length &&
|
||||||
|
lines[i].trim() &&
|
||||||
|
!/^#{1,6}\s+/.test(lines[i]) &&
|
||||||
|
!/^\s*[-*+]\s+/.test(lines[i]) &&
|
||||||
|
!/^\s*\d+\.\s+/.test(lines[i])
|
||||||
|
) {
|
||||||
|
buf.push(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out.push({ kind: 'paragraph', text: buf.join('\n') })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(b: Block): ReactNode {
|
||||||
|
if (b.kind === 'heading') {
|
||||||
|
const sizes = [0, 24, 20, 18, 16, 15, 14]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
fontSize: sizes[b.level] ?? 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
margin: '18px 0 6px',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (b.kind === 'paragraph') {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 10px',
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (b.kind === 'ul') {
|
||||||
|
return (
|
||||||
|
<ul style={{ margin: '0 0 10px', paddingLeft: 20, lineHeight: 1.55 }}>
|
||||||
|
{b.items.map((it, i) => (
|
||||||
|
<li key={i}>{renderInline(it)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol style={{ margin: '0 0 10px', paddingLeft: 22, lineHeight: 1.55 }}>
|
||||||
|
{b.items.map((it, i) => (
|
||||||
|
<li key={i}>{renderInline(it)}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): ReactNode {
|
||||||
|
// Order matters: links → code → bold → italic. Linebreaks preserved by whiteSpace: pre-wrap.
|
||||||
|
const out: ReactNode[] = []
|
||||||
|
let rest = text
|
||||||
|
while (rest.length > 0) {
|
||||||
|
const linkMatch = rest.match(/^\[([^\]]+)\]\(([^)]+)\)/)
|
||||||
|
if (linkMatch) {
|
||||||
|
out.push(
|
||||||
|
<a
|
||||||
|
key={out.length}
|
||||||
|
href={linkMatch[2]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--primary)', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{renderInline(linkMatch[1])}
|
||||||
|
</a>,
|
||||||
|
)
|
||||||
|
rest = rest.slice(linkMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const codeMatch = rest.match(/^`([^`]+)`/)
|
||||||
|
if (codeMatch) {
|
||||||
|
out.push(
|
||||||
|
<code
|
||||||
|
key={out.length}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{codeMatch[1]}
|
||||||
|
</code>,
|
||||||
|
)
|
||||||
|
rest = rest.slice(codeMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const boldMatch = rest.match(/^\*\*([^*]+)\*\*/)
|
||||||
|
if (boldMatch) {
|
||||||
|
out.push(
|
||||||
|
<strong key={out.length} style={{ fontWeight: 600 }}>
|
||||||
|
{renderInline(boldMatch[1])}
|
||||||
|
</strong>,
|
||||||
|
)
|
||||||
|
rest = rest.slice(boldMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const italicMatch = rest.match(/^\*([^*]+)\*/) || rest.match(/^_([^_]+)_/)
|
||||||
|
if (italicMatch) {
|
||||||
|
out.push(
|
||||||
|
<em key={out.length} style={{ fontStyle: 'italic' }}>
|
||||||
|
{renderInline(italicMatch[1])}
|
||||||
|
</em>,
|
||||||
|
)
|
||||||
|
rest = rest.slice(italicMatch[0].length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Take one character and move on.
|
||||||
|
out.push(rest[0])
|
||||||
|
rest = rest.slice(1)
|
||||||
|
}
|
||||||
|
return <>{out}</>
|
||||||
|
}
|
||||||
77
ui/src/lib/transcriptMarkdown.ts
Normal file
77
ui/src/lib/transcriptMarkdown.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
|
type Transcript = components['schemas']['GetTranscriptWithParticipants']
|
||||||
|
type Topic = components['schemas']['GetTranscriptTopic']
|
||||||
|
type Segment = components['schemas']['GetTranscriptSegmentTopic']
|
||||||
|
type Participant = components['schemas']['Participant']
|
||||||
|
|
||||||
|
function pad2(n: number) {
|
||||||
|
return String(Math.floor(n)).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTs(seconds: number): string {
|
||||||
|
if (!seconds || seconds < 0) return '00:00'
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (m < 60) return `${pad2(m)}:${pad2(s)}`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${pad2(h)}:${pad2(m % 60)}:${pad2(s)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerNameFor(
|
||||||
|
speaker: number,
|
||||||
|
participants: Participant[] | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!participants) return `Speaker ${speaker}`
|
||||||
|
const found = participants.find((p) => p.speaker === speaker)
|
||||||
|
return found?.name?.trim() || `Speaker ${speaker}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a markdown string for a transcript + topics, suitable for copy-to-clipboard.
|
||||||
|
* Mirrors www's `buildTranscriptWithTopics` in tone and structure.
|
||||||
|
*/
|
||||||
|
export function buildTranscriptMarkdown(
|
||||||
|
transcript: Transcript,
|
||||||
|
topics: Topic[] | null | undefined,
|
||||||
|
participants: Participant[] | null | undefined,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
const title = transcript.title?.trim() || transcript.name?.trim() || 'Transcript'
|
||||||
|
lines.push(`# ${title}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
if (transcript.long_summary?.trim()) {
|
||||||
|
lines.push('## Summary')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(transcript.long_summary.trim())
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = topics ?? []
|
||||||
|
if (ts.length === 0) {
|
||||||
|
return lines.join('\n').trimEnd() + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const topic of ts) {
|
||||||
|
const headerTs = fmtTs(topic.timestamp ?? 0)
|
||||||
|
lines.push(`## ${topic.title} (${headerTs})`)
|
||||||
|
if (topic.summary?.trim()) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push(topic.summary.trim())
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
const segments: Segment[] = topic.segments ?? []
|
||||||
|
if (segments.length > 0) {
|
||||||
|
for (const seg of segments) {
|
||||||
|
const name = speakerNameFor(seg.speaker, participants)
|
||||||
|
lines.push(`**${name}**: ${seg.text}`)
|
||||||
|
}
|
||||||
|
} else if (topic.transcript?.trim()) {
|
||||||
|
lines.push(topic.transcript.trim())
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trimEnd() + '\n'
|
||||||
|
}
|
||||||
80
ui/src/lib/types.ts
Normal file
80
ui/src/lib/types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export type TranscriptStatus = 'live' | 'ended' | 'processing' | 'uploading' | 'failed' | 'idle'
|
||||||
|
|
||||||
|
export type TranscriptSource = 'room' | 'upload' | 'live'
|
||||||
|
|
||||||
|
export type TranscriptRowData = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: TranscriptStatus
|
||||||
|
source: TranscriptSource
|
||||||
|
room: string | null
|
||||||
|
date: string
|
||||||
|
duration: number
|
||||||
|
speakers: number
|
||||||
|
lang: string
|
||||||
|
tags: string[]
|
||||||
|
snippet: string | null
|
||||||
|
progress?: number
|
||||||
|
stage?: string
|
||||||
|
eta?: string
|
||||||
|
error?: string
|
||||||
|
error_message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrashRowData = TranscriptRowData & {
|
||||||
|
deleted_at: string
|
||||||
|
days_remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomRowData = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
shared: boolean
|
||||||
|
/** Optional transcript count for sidebar display. `null` = render without a badge. */
|
||||||
|
count: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagRowData = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarFilter =
|
||||||
|
| { kind: 'all'; value: null }
|
||||||
|
| { kind: 'recent'; value: null }
|
||||||
|
| { kind: 'source'; value: 'live' | 'file' }
|
||||||
|
| { kind: 'room'; value: string }
|
||||||
|
| { kind: 'tag'; value: string }
|
||||||
|
| { kind: 'trash'; value: null }
|
||||||
|
|
||||||
|
export type RoomsFilter =
|
||||||
|
| { kind: 'all'; value: null }
|
||||||
|
| { kind: 'scope'; value: 'mine' | 'shared' }
|
||||||
|
| { kind: 'status'; value: 'active' | 'calendar' }
|
||||||
|
| { kind: 'platform'; value: 'whereby' | 'daily' | 'livekit' }
|
||||||
|
| { kind: 'size'; value: 'normal' | 'group' }
|
||||||
|
| { kind: 'recording'; value: 'cloud' | 'local' | 'none' }
|
||||||
|
|
||||||
|
export const LANG_LABELS: Record<string, string> = {
|
||||||
|
en: 'EN',
|
||||||
|
'en→es': 'EN→ES',
|
||||||
|
'fr→en': 'FR→EN',
|
||||||
|
'de→en': 'DE→EN',
|
||||||
|
es: 'ES',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REFLECTOR_LANGS = [
|
||||||
|
{ code: 'auto', name: 'Auto-detect', flag: '🌐' },
|
||||||
|
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||||
|
{ code: 'es', name: 'Spanish', flag: '🇪🇸' },
|
||||||
|
{ code: 'fr', name: 'French', flag: '🇫🇷' },
|
||||||
|
{ code: 'de', name: 'German', flag: '🇩🇪' },
|
||||||
|
{ code: 'pt', name: 'Portuguese', flag: '🇵🇹' },
|
||||||
|
{ code: 'it', name: 'Italian', flag: '🇮🇹' },
|
||||||
|
{ code: 'nl', name: 'Dutch', flag: '🇳🇱' },
|
||||||
|
{ code: 'ja', name: 'Japanese', flag: '🇯🇵' },
|
||||||
|
{ code: 'zh', name: 'Mandarin', flag: '🇨🇳' },
|
||||||
|
{ code: 'ko', name: 'Korean', flag: '🇰🇷' },
|
||||||
|
{ code: 'ar', name: 'Arabic', flag: '🇸🇦' },
|
||||||
|
] as const
|
||||||
6
ui/src/lib/utils.ts
Normal file
6
ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './styles/index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
26
ui/src/pages/AuthCallback.tsx
Normal file
26
ui/src/pages/AuthCallback.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
|
||||||
|
export function AuthCallbackPage() {
|
||||||
|
const { authenticated, loading } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && authenticated) navigate('/', { replace: true })
|
||||||
|
}, [authenticated, loading, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Signing you in…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
321
ui/src/pages/BrowsePage.tsx
Normal file
321
ui/src/pages/BrowsePage.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
useQueryState,
|
||||||
|
parseAsString,
|
||||||
|
parseAsInteger,
|
||||||
|
parseAsStringLiteral,
|
||||||
|
} from 'nuqs'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { AppSidebar } from '@/components/layout/AppSidebar'
|
||||||
|
import { NewTranscriptDialog } from '@/components/shared/NewTranscriptDialog'
|
||||||
|
import { FilterBar } from '@/components/browse/FilterBar'
|
||||||
|
import { Pagination } from '@/components/browse/Pagination'
|
||||||
|
import { TranscriptRow } from '@/components/browse/TranscriptRow'
|
||||||
|
import { TrashRow } from '@/components/browse/TrashRow'
|
||||||
|
import { ConfirmDialog } from '@/components/browse/ConfirmDialog'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { extractDetail, messageFor } from '@/lib/apiErrors'
|
||||||
|
import { useRooms } from '@/hooks/useRooms'
|
||||||
|
import { useTranscripts } from '@/hooks/useTranscripts'
|
||||||
|
import type { SidebarFilter, TranscriptRowData } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const sourceParser = parseAsStringLiteral(['live', 'file'] as const)
|
||||||
|
const sortParser = parseAsStringLiteral(['newest', 'oldest', 'longest'] as const).withDefault('newest')
|
||||||
|
|
||||||
|
|
||||||
|
export function BrowsePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: rooms = [] } = useRooms()
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [newOpen, setNewOpen] = useState(false)
|
||||||
|
const [toDelete, setToDelete] = useState<TranscriptRowData | null>(null)
|
||||||
|
const [toDestroy, setToDestroy] = useState<TranscriptRowData | null>(null)
|
||||||
|
|
||||||
|
const invalidateList = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['transcripts'] })
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { response, error } = await apiClient.DELETE('/v1/transcripts/{transcript_id}', {
|
||||||
|
params: { path: { transcript_id: id } },
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Delete failed'), { detail: extractDetail(error) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateList()
|
||||||
|
toast.success('Moved to trash')
|
||||||
|
setToDelete(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(messageFor(err, 'Delete failed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const restoreMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { response, error } = await apiClient.POST(
|
||||||
|
'/v1/transcripts/{transcript_id}/restore',
|
||||||
|
{ params: { path: { transcript_id: id } } },
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Restore failed'), { detail: extractDetail(error) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateList()
|
||||||
|
toast.success('Restored')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(messageFor(err, 'Restore failed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const destroyMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { response, error } = await apiClient.DELETE(
|
||||||
|
'/v1/transcripts/{transcript_id}/destroy',
|
||||||
|
{ params: { path: { transcript_id: id } } },
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(new Error('Destroy failed'), { detail: extractDetail(error) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateList()
|
||||||
|
toast.success('Permanently destroyed')
|
||||||
|
setToDestroy(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(messageFor(err, 'Destroy failed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [q, setQ] = useQueryState('q', parseAsString.withDefault(''))
|
||||||
|
const [source, setSource] = useQueryState('source', sourceParser)
|
||||||
|
const [roomId, setRoomId] = useQueryState('room', parseAsString)
|
||||||
|
const [trash, setTrash] = useQueryState('trash', parseAsInteger)
|
||||||
|
const [tagId, setTagId] = useQueryState('tag', parseAsString)
|
||||||
|
const [recent, setRecent] = useQueryState('recent', parseAsInteger)
|
||||||
|
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
|
||||||
|
const [sort, setSort] = useQueryState('sort', sortParser)
|
||||||
|
|
||||||
|
const filter: SidebarFilter = useMemo(() => {
|
||||||
|
if (trash) return { kind: 'trash', value: null }
|
||||||
|
if (recent) return { kind: 'recent', value: null }
|
||||||
|
if (tagId) return { kind: 'tag', value: tagId }
|
||||||
|
if (source === 'live' || source === 'file') {
|
||||||
|
if (roomId) return { kind: 'room', value: roomId }
|
||||||
|
return { kind: 'source', value: source }
|
||||||
|
}
|
||||||
|
if (roomId) return { kind: 'room', value: roomId }
|
||||||
|
return { kind: 'all', value: null }
|
||||||
|
}, [trash, recent, tagId, source, roomId])
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setTrash(null)
|
||||||
|
setRecent(null)
|
||||||
|
setSource(null)
|
||||||
|
setRoomId(null)
|
||||||
|
setTagId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFilter = (f: SidebarFilter) => {
|
||||||
|
setPage(1)
|
||||||
|
if (f.kind === 'trash') {
|
||||||
|
clearAll()
|
||||||
|
setTrash(1)
|
||||||
|
} else if (f.kind === 'recent') {
|
||||||
|
clearAll()
|
||||||
|
setRecent(1)
|
||||||
|
} else if (f.kind === 'source') {
|
||||||
|
clearAll()
|
||||||
|
setSource(f.value)
|
||||||
|
} else if (f.kind === 'room') {
|
||||||
|
clearAll()
|
||||||
|
setRoomId(f.value)
|
||||||
|
} else if (f.kind === 'tag') {
|
||||||
|
clearAll()
|
||||||
|
setTagId(f.value)
|
||||||
|
} else {
|
||||||
|
clearAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceKind = filter.kind === 'source' ? filter.value : undefined
|
||||||
|
const queryRoomId = filter.kind === 'room' ? filter.value : undefined
|
||||||
|
|
||||||
|
const { data, isLoading } = useTranscripts({
|
||||||
|
query: q,
|
||||||
|
page: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
sourceKind,
|
||||||
|
roomId: queryRoomId,
|
||||||
|
includeDeleted: filter.kind === 'trash',
|
||||||
|
sinceDays: filter.kind === 'recent' ? 7 : null,
|
||||||
|
sort,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = data?.items ?? []
|
||||||
|
const total = data?.total ?? 0
|
||||||
|
|
||||||
|
// Unfiltered grand total for "All transcripts" — fetched once, cached long.
|
||||||
|
const allTotalQuery = useTranscripts({
|
||||||
|
query: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
})
|
||||||
|
const allTotal = allTotalQuery.data?.total ?? null
|
||||||
|
|
||||||
|
// Per-filter counts: only the count corresponding to the active filter is
|
||||||
|
// updated from the current query. Non-active rows stay at `null` → rendered
|
||||||
|
// as no-badge instead of a misleading "0".
|
||||||
|
const sidebarCounts = {
|
||||||
|
all: allTotal,
|
||||||
|
liveTranscripts:
|
||||||
|
filter.kind === 'source' && filter.value === 'live' ? total : null,
|
||||||
|
uploadedFiles:
|
||||||
|
filter.kind === 'source' && filter.value === 'file' ? total : null,
|
||||||
|
trash: filter.kind === 'trash' ? total : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the filtered count on the active room; other rooms stay unbadged.
|
||||||
|
// The backend doesn't expose a per-room transcript count today.
|
||||||
|
const roomsWithCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
rooms.map((r) => ({
|
||||||
|
...r,
|
||||||
|
count: filter.kind === 'room' && filter.value === r.id ? total : null,
|
||||||
|
})),
|
||||||
|
[rooms, filter, total],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
title="Browse"
|
||||||
|
crumb={['reflector', 'transcripts']}
|
||||||
|
sidebar={
|
||||||
|
<AppSidebar
|
||||||
|
filter={filter}
|
||||||
|
onFilter={onFilter}
|
||||||
|
rooms={roomsWithCounts}
|
||||||
|
tags={[]}
|
||||||
|
showTags={false}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggle={() => setCollapsed((v) => !v)}
|
||||||
|
onNewRecording={() => setNewOpen(true)}
|
||||||
|
counts={sidebarCounts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: 'var(--shadow-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterBar
|
||||||
|
filter={filter}
|
||||||
|
rooms={rooms}
|
||||||
|
tags={[]}
|
||||||
|
total={total}
|
||||||
|
sort={sort}
|
||||||
|
onSort={(s) => setSort(s)}
|
||||||
|
query={q}
|
||||||
|
onSearch={(v) => {
|
||||||
|
setQ(v || null)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && items.length === 0 ? (
|
||||||
|
<div style={{ padding: 32, textAlign: 'center', color: 'var(--fg-muted)' }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '64px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No transcripts to show.
|
||||||
|
</div>
|
||||||
|
) : filter.kind === 'trash' ? (
|
||||||
|
items.map((t) => (
|
||||||
|
<TrashRow
|
||||||
|
key={t.id}
|
||||||
|
t={t}
|
||||||
|
onRestore={(id) => restoreMutation.mutate(id)}
|
||||||
|
onDestroy={(x) => setToDestroy(x)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
items.map((t) => (
|
||||||
|
<TranscriptRow
|
||||||
|
key={t.id}
|
||||||
|
t={t}
|
||||||
|
query={q}
|
||||||
|
onSelect={(id) => navigate(`/transcripts/${id}`)}
|
||||||
|
onDelete={(x) => setToDelete(x)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPage={(n) => setPage(n)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newOpen && <NewTranscriptDialog onClose={() => setNewOpen(false)} />}
|
||||||
|
|
||||||
|
{toDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Move to trash?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<strong style={{ color: 'var(--fg)' }}>
|
||||||
|
{toDelete.title || 'Unnamed transcript'}
|
||||||
|
</strong>{' '}
|
||||||
|
will be moved to the trash. You can restore it later from the trash view.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
|
danger
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleteMutation.mutate(toDelete.id)}
|
||||||
|
onClose={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toDestroy && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Destroy permanently?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<strong style={{ color: 'var(--fg)' }}>
|
||||||
|
{toDestroy.title || 'Unnamed transcript'}
|
||||||
|
</strong>{' '}
|
||||||
|
and all its associated files will be permanently deleted. This can't be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Destroy permanently"
|
||||||
|
danger
|
||||||
|
loading={destroyMutation.isPending}
|
||||||
|
onConfirm={() => destroyMutation.mutate(toDestroy.id)}
|
||||||
|
onClose={() => setToDestroy(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user