feat: new ui with greyhaven design system

This commit is contained in:
Juan
2026-04-23 15:01:05 -05:00
parent dc428b2042
commit 0956647dbc
206 changed files with 18978 additions and 24703 deletions

6
ui/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
.git
.DS_Store

10
ui/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

24
ui/public/icons.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
}

View 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>
)
}

View 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
View 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/')
},
}
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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),
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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="24 people"
count={sizeCount('normal')}
active={isActive('size', 'normal')}
onClick={() => onFilter({ kind: 'size', value: 'normal' })}
/>
<SidebarItem
icon={I.Users(14)}
label="2200 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>
)
}

View 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>
)
}

View 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

View 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>
</>
)
}

View 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">24 people</option>
<option value="group">2200 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>
</>
)
}

View 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>
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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')}`
}

View 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'
}

View 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 {}
}

View 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'
}

View 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>
)
}

View 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
View 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,
})
}

View 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 }
}

View 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])
}

View 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
View 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
View 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
View 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
View 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}</>
}

View 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
View 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
View 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
View 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>,
)

View 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
View 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>
)
}

178
ui/src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { I } from '@/components/icons'
import { AppShell } from '@/components/layout/AppShell'
import { AppSidebar } from '@/components/layout/AppSidebar'
import { Button } from '@/components/ui/primitives'
import { LanguagePair } from '@/components/home/LanguagePair'
import { RoomPicker } from '@/components/home/RoomPicker'
import { useRooms } from '@/hooks/useRooms'
import { apiClient } from '@/api/client'
import type { SidebarFilter } from '@/lib/types'
export function HomePage() {
const navigate = useNavigate()
const { data: rooms = [] } = useRooms()
const [filter, setFilter] = useState<SidebarFilter>({ kind: 'all', value: null })
const [collapsed, setCollapsed] = useState(false)
const [title, setTitle] = useState('')
const [sourceLang, setSourceLang] = useState('auto')
const [targetLang, setTargetLang] = useState('')
const [roomId, setRoomId] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleFilter = (f: SidebarFilter) => {
setFilter(f)
if (f.kind === 'trash') navigate('/browse?trash=1')
else if (f.kind === 'source') navigate(`/browse?source=${f.value}`)
else if (f.kind === 'room') navigate(`/browse?source=room&room=${f.value}`)
else if (f.kind === 'all' || f.kind === 'recent') navigate('/browse')
}
const handleStart = 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
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 (
<AppShell
title="New transcript"
crumb={['home', 'new']}
sidebar={
<AppSidebar
filter={filter}
onFilter={handleFilter}
rooms={rooms}
tags={[]}
showTags={false}
collapsed={collapsed}
onToggle={() => setCollapsed((v) => !v)}
onNewRecording={handleStart}
/>
}
>
<div style={{ maxWidth: 560, margin: '20px auto 0', padding: '0 4px 80px' }}>
<header style={{ marginBottom: 24 }}>
<h1
style={{
fontFamily: 'var(--font-serif)',
fontSize: 32,
fontWeight: 600,
letterSpacing: '-0.02em',
margin: 0,
color: 'var(--fg)',
}}
>
New transcript
</h1>
<p
style={{
fontSize: 14,
color: 'var(--fg-muted)',
marginTop: 6,
fontFamily: 'var(--font-sans)',
}}
>
Record live or upload a file. You can edit details later.
</p>
</header>
<div
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: 24,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label className="rf-label" htmlFor="rf-title">
Title
</label>
<input
id="rf-title"
className="rf-input"
type="text"
placeholder="e.g. Sprint review — June 12"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ marginTop: 6 }}
/>
</div>
<LanguagePair
sourceLang={sourceLang}
setSourceLang={setSourceLang}
targetLang={targetLang}
setTargetLang={setTargetLang}
/>
<RoomPicker roomId={roomId} setRoomId={setRoomId} rooms={rooms} />
</div>
<div
style={{
display: 'flex',
gap: 10,
marginTop: 24,
paddingTop: 20,
borderTop: '1px solid var(--border)',
}}
>
<Button
variant="primary"
size="md"
onClick={handleStart}
disabled={submitting}
style={{ flex: 1 }}
>
{I.Mic(14)} {submitting ? 'Starting…' : 'Start recording'}
</Button>
<Button variant="secondary" size="md" onClick={handleUpload} style={{ flex: 1 }}>
{I.Upload(14)} Upload audio
</Button>
</div>
<div
style={{
marginTop: 14,
fontSize: 11.5,
color: 'var(--fg-muted)',
display: 'flex',
alignItems: 'center',
gap: 6,
fontFamily: 'var(--font-sans)',
}}
>
{I.Lock(12)}
Audio is processed on your infrastructure.
</div>
</div>
</div>
</AppShell>
)
}

325
ui/src/pages/LoggedOut.tsx Normal file
View File

@@ -0,0 +1,325 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { I } from '@/components/icons'
import { Button } from '@/components/ui/primitives'
import { ReflectorMark } from '@/components/layout/ReflectorMark'
import { useAuth } from '@/auth/AuthContext'
export function LoggedOutPage() {
const { mode, loginWithOidc } = useAuth()
const navigate = useNavigate()
const [learnOpen, setLearnOpen] = useState(false)
const [privacyOpen, setPrivacyOpen] = useState(false)
const handleSignIn = () => {
if (mode === 'oidc') loginWithOidc()
else navigate('/login')
}
return (
<>
<main
style={{
maxWidth: 520,
margin: '0 auto',
minHeight: '100vh',
padding: '48px 24px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
<div
style={{
width: 72,
height: 72,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
background: 'var(--reflector-accent-tint)',
borderRadius: '50%',
}}
>
<ReflectorMark size={40} />
</div>
<div
style={{
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.14em',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-mono)',
marginBottom: 14,
}}
>
Reflector · by Greyhaven
</div>
<h1
style={{
fontFamily: 'var(--font-serif)',
fontSize: 44,
fontWeight: 600,
letterSpacing: '-0.025em',
margin: 0,
lineHeight: 1.05,
color: 'var(--fg)',
}}
>
Transcripts &amp; translation,
<br />
<span style={{ fontStyle: 'italic', color: 'var(--fg-muted)' }}>
on your own infrastructure.
</span>
</h1>
<p
style={{
fontSize: 15.5,
color: 'var(--fg-muted)',
marginTop: 18,
fontFamily: 'var(--font-sans)',
maxWidth: 420,
lineHeight: 1.55,
}}
>
Record meetings, upload audio, translate between 40+ languages. Hosted, operated and
owned by your team. No third-party AI vendor touches the audio.
</p>
<div style={{ display: 'flex', gap: 10, marginTop: 28, alignItems: 'center' }}>
<Button variant="primary" size="md" onClick={handleSignIn}>
Sign in to continue
</Button>
<Button variant="ghost" size="md" onClick={() => setLearnOpen(true)}>
What is this?
</Button>
</div>
<div
style={{
marginTop: 48,
paddingTop: 24,
borderTop: '1px solid var(--border)',
width: '100%',
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{I.Lock(12)} Self-hosted
</span>
<button
type="button"
onClick={() => setPrivacyOpen(true)}
style={{
background: 'none',
border: 'none',
padding: 0,
color: 'inherit',
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 'inherit',
textDecoration: 'underline',
textUnderlineOffset: 2,
}}
>
Privacy &amp; retention
</button>
<a
href="https://greyhaven.co"
target="_blank"
rel="noreferrer"
style={{
color: 'inherit',
textDecoration: 'underline',
textUnderlineOffset: 2,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
greyhaven.co {I.ExternalLink(11)}
</a>
</div>
</main>
{learnOpen && <LearnMoreDialog onClose={() => setLearnOpen(false)} />}
{privacyOpen && <PrivacyDialog onClose={() => setPrivacyOpen(false)} />}
</>
)
}
function PrivacyDialog({ onClose }: { onClose: () => void }) {
return (
<>
<div className="rf-modal-backdrop" onClick={onClose} />
<div className="rf-modal" role="dialog" aria-modal="true">
<div
style={{
padding: '20px 24px 14px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h2
style={{
margin: 0,
fontFamily: 'var(--font-serif)',
fontSize: 20,
fontWeight: 600,
letterSpacing: '-0.015em',
color: 'var(--fg)',
}}
>
Privacy Policy
</h2>
<button
onClick={onClose}
aria-label="Close"
style={{
border: 'none',
background: 'transparent',
color: 'var(--fg-muted)',
cursor: 'pointer',
padding: 4,
borderRadius: 4,
display: 'inline-flex',
}}
>
{I.Close(18)}
</button>
</div>
<div
style={{
padding: '18px 24px 22px',
color: 'var(--fg)',
fontFamily: 'var(--font-sans)',
fontSize: 14,
lineHeight: 1.6,
overflowY: 'auto',
maxHeight: 'calc(100vh - 180px)',
}}
>
<p
style={{
marginTop: 0,
marginBottom: 14,
fontStyle: 'italic',
color: 'var(--fg-muted)',
fontSize: 13,
}}
>
Last updated on September 22, 2023
</p>
<ul style={{ paddingLeft: 18, margin: '0 0 14px' }}>
<li style={{ marginBottom: 10 }}>
Recording Consent: By using Reflector, you grant us permission to record your
interactions for the purpose of showcasing Reflector's capabilities during the All
In AI conference.
</li>
<li style={{ marginBottom: 10 }}>
Data Access: You will have convenient access to your recorded sessions and
transcriptions via a unique URL, which remains active for a period of seven days.
After this time, your recordings and transcripts will be deleted.
</li>
<li style={{ marginBottom: 10 }}>
Data Confidentiality: Rest assured that none of your audio data will be shared with
third parties.
</li>
</ul>
<p style={{ margin: 0 }}>
Questions or Concerns: If you have any questions or concerns regarding your data,
please feel free to reach out to us at{' '}
<a
href="mailto:reflector@monadical.com"
style={{
color: 'var(--primary)',
textDecoration: 'underline',
textUnderlineOffset: 2,
}}
>
reflector@monadical.com
</a>
.
</p>
</div>
</div>
</>
)
}
function LearnMoreDialog({ onClose }: { onClose: () => void }) {
return (
<>
<div className="rf-modal-backdrop" onClick={onClose} />
<div className="rf-modal" role="dialog" aria-modal="true">
<div
style={{
padding: '20px 24px 14px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h2
style={{
margin: 0,
fontFamily: 'var(--font-serif)',
fontSize: 20,
fontWeight: 600,
letterSpacing: '-0.015em',
color: 'var(--fg)',
}}
>
What is Reflector?
</h2>
<button
onClick={onClose}
aria-label="Close"
style={{
border: 'none',
background: 'transparent',
color: 'var(--fg-muted)',
cursor: 'pointer',
padding: 4,
borderRadius: 4,
display: 'inline-flex',
}}
>
{I.Close(18)}
</button>
</div>
<div
style={{
padding: '18px 24px 22px',
color: 'var(--fg)',
fontFamily: 'var(--font-sans)',
fontSize: 14,
lineHeight: 1.6,
}}
>
<p style={{ marginTop: 0 }}>
Reflector turns meetings and audio files into searchable transcripts and translations.
It runs on your infrastructure, so no third-party AI vendor touches the audio.
</p>
<p style={{ marginBottom: 0 }}>
Record live from your browser, upload existing files, or connect a meeting room. The
processing pipeline (transcription, diarization, translation, summarization) is
open-source and self-hosted.
</p>
</div>
</div>
</>
)
}

133
ui/src/pages/LoginForm.tsx Normal file
View File

@@ -0,0 +1,133 @@
import { useState, type CSSProperties } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/primitives'
import { useAuth } from '@/auth/AuthContext'
export function LoginForm() {
const { loginWithPassword } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!email || !password) {
setError('Email and password are required.')
return
}
setLoading(true)
try {
await loginWithPassword(email, password)
navigate('/', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed')
} finally {
setLoading(false)
}
}
const inputStyle: CSSProperties = {
width: '100%',
height: 40,
padding: '0 12px',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--fg)',
fontFamily: 'var(--font-sans)',
fontSize: 14,
outline: 'none',
}
return (
<main style={{ maxWidth: 400, margin: '0 auto', padding: '100px 24px 60px' }}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
<h1
style={{
fontFamily: 'var(--font-serif)',
fontSize: 32,
fontWeight: 600,
letterSpacing: '-0.02em',
margin: 0,
lineHeight: 1.1,
color: 'var(--fg)',
}}
>
Log in
</h1>
{error && (
<div
role="alert"
style={{
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',
}}
>
{error}
</div>
)}
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg)' }}>
Email <span style={{ color: 'var(--destructive)' }}>*</span>
</span>
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
style={inputStyle}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg)' }}>
Password <span style={{ color: 'var(--destructive)' }}>*</span>
</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
/>
</label>
<Button
type="submit"
variant="primary"
disabled={loading}
style={{ width: '100%', height: 40 }}
>
{loading ? 'Signing in…' : 'Log in'}
</Button>
<button
type="button"
onClick={() => navigate('/welcome')}
style={{
background: 'transparent',
border: 'none',
color: 'var(--fg-muted)',
fontSize: 13,
fontFamily: 'var(--font-sans)',
cursor: 'pointer',
textAlign: 'center',
padding: 0,
}}
>
Back
</button>
</form>
</main>
)
}

406
ui/src/pages/RoomsPage.tsx Normal file
View File

@@ -0,0 +1,406 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { AppShell } from '@/components/layout/AppShell'
import { RoomsSidebar } from '@/components/layout/RoomsSidebar'
import { NewTranscriptDialog } from '@/components/shared/NewTranscriptDialog'
import { Button } from '@/components/ui/primitives'
import { I } from '@/components/icons'
import { RoomsTable } from '@/components/rooms/RoomsTable'
import { RoomFormDialog, type RoomFormPayload } from '@/components/rooms/RoomFormDialog'
import { DeleteRoomDialog } from '@/components/rooms/DeleteRoomDialog'
import { apiClient } from '@/api/client'
import { extractDetail, messageFor } from '@/lib/apiErrors'
import type { components } from '@/api/schema'
import type { RoomsFilter } from '@/lib/types'
type Room = components['schemas']['RoomDetails']
const EMPTY_ROOMS: Room[] = []
function matchesFilter(room: Room, filter: RoomsFilter) {
if (filter.kind === 'all') return true
if (filter.kind === 'scope')
return filter.value === 'mine' ? !room.is_shared : room.is_shared
if (filter.kind === 'status') {
if (filter.value === 'active') return false
if (filter.value === 'calendar') return room.ics_enabled
}
if (filter.kind === 'platform') return room.platform === filter.value
if (filter.kind === 'size') return room.room_mode === filter.value
if (filter.kind === 'recording') return room.recording_type === filter.value
return true
}
function titleFor(filter: RoomsFilter) {
if (filter.kind === 'all') return 'Rooms'
if (filter.kind === 'scope')
return filter.value === 'mine' ? 'My rooms' : 'Shared rooms'
if (filter.kind === 'status') {
if (filter.value === 'active') return 'Active rooms'
if (filter.value === 'calendar') return 'Calendar-linked rooms'
}
if (filter.kind === 'platform')
return `${filter.value.charAt(0).toUpperCase() + filter.value.slice(1)} rooms`
if (filter.kind === 'size')
return filter.value === 'group' ? 'Group rooms (2200)' : 'Small rooms (24)'
if (filter.kind === 'recording') return `Recording: ${filter.value}`
return 'Rooms'
}
export function RoomsPage() {
const queryClient = useQueryClient()
const [filter, setFilter] = useState<RoomsFilter>({ kind: 'all', value: null })
const [collapsed, setCollapsed] = useState(false)
const [search, setSearch] = useState('')
const [formRoom, setFormRoom] = useState<Room | null | 'new'>(null)
const [deleteRoom, setDeleteRoom] = useState<Room | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [newOpen, setNewOpen] = useState(false)
const roomsQuery = useQuery({
queryKey: ['rooms', 'all'],
queryFn: async () => {
const { data, response } = await apiClient.GET('/v1/rooms', {
params: { query: { page: 1, size: 100 } as never },
})
if (!response.ok || !data) throw new Error('Failed to load rooms')
return (data as { items: Room[] }).items
},
})
const rooms = roomsQuery.data ?? EMPTY_ROOMS
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
return rooms.filter((r) => {
if (!matchesFilter(r, filter)) return false
if (q && !r.name.toLowerCase().includes(q)) return false
return true
})
}, [rooms, filter, search])
const mine = filtered.filter((r) => !r.is_shared)
const shared = filtered.filter((r) => r.is_shared)
const createRoom = useMutation({
mutationFn: async (body: RoomFormPayload) => {
const { data, response, error } = await apiClient.POST('/v1/rooms', {
body: body as never,
})
if (!response.ok || !data) {
throw Object.assign(new Error('Could not create room'), {
status: response.status,
detail: extractDetail(error),
})
}
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] })
toast.success('Room created')
setFormRoom(null)
},
onError: (err) => toast.error(messageFor(err, 'Create failed')),
})
const updateRoom = useMutation({
mutationFn: async ({ id, patch }: { id: string; patch: RoomFormPayload }) => {
const { name: _name, ...updatable } = patch
const { data, response, error } = await apiClient.PATCH('/v1/rooms/{room_id}', {
params: { path: { room_id: id } },
body: updatable as never,
})
if (!response.ok || !data) {
throw Object.assign(new Error('Could not update room'), {
status: response.status,
detail: extractDetail(error),
})
}
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] })
toast.success('Room updated')
setFormRoom(null)
},
onError: (err) => toast.error(messageFor(err, 'Update failed')),
})
const destroyRoom = useMutation({
mutationFn: async (id: string) => {
const { response } = await apiClient.DELETE('/v1/rooms/{room_id}', {
params: { path: { room_id: id } },
})
if (!response.ok) throw new Error('Could not delete room')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] })
toast.success('Room deleted')
setDeleteRoom(null)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Delete failed'),
})
const handleSave = async (patch: RoomFormPayload) => {
if (formRoom && formRoom !== 'new') {
await updateRoom.mutateAsync({ id: formRoom.id, patch })
} else {
await createRoom.mutateAsync(patch)
}
}
const handleCopy = (r: Room) => {
navigator.clipboard.writeText(`${window.location.origin}/${r.name}`).then(
() => {
setCopiedId(r.id)
setTimeout(
() => setCopiedId((prev) => (prev === r.id ? null : prev)),
1500,
)
},
() => toast.error('Could not copy'),
)
}
const showBothSections = filter.kind === 'all'
return (
<AppShell
title={titleFor(filter)}
crumb={['workspace', 'rooms']}
sidebar={
<RoomsSidebar
filter={filter}
onFilter={setFilter}
rooms={rooms}
collapsed={collapsed}
onToggle={() => setCollapsed((v) => !v)}
onNewRecording={() => setNewOpen(true)}
/>
}
>
<div
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
boxShadow: 'var(--shadow-xs)',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 20px',
borderBottom: '1px solid var(--border)',
background: 'var(--card)',
fontFamily: 'var(--font-sans)',
}}
>
<span style={{ color: 'var(--fg)', fontWeight: 600, fontSize: 13 }}>
{filtered.length} {filtered.length === 1 ? 'room' : 'rooms'}
</span>
<div
style={{
marginLeft: 4,
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={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search rooms, streams, topics…"
style={{
border: 'none',
outline: 'none',
background: 'transparent',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
color: 'var(--fg)',
flex: 1,
}}
/>
</div>
<div style={{ flex: 1 }} />
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['rooms'] })}
>
{I.Refresh(13)} Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setFormRoom('new')}>
{I.Plus(13)} New room
</Button>
</div>
<div style={{ overflowY: 'auto' }}>
{roomsQuery.isLoading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--fg-muted)' }}>
Loading
</div>
) : filtered.length === 0 ? (
<div
style={{
padding: '80px 24px',
textAlign: 'center',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 9999,
background: 'var(--muted)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--gh-grey-4)',
marginBottom: 12,
}}
>
{I.Door(22)}
</div>
<div
style={{
fontFamily: 'var(--font-serif)',
fontSize: 18,
fontWeight: 600,
color: 'var(--fg)',
}}
>
{search ? `No rooms match “${search}` : 'No rooms match this filter'}
</div>
<div style={{ fontSize: 13, maxWidth: 360, margin: '8px auto 0' }}>
{search
? 'Try a different term, or clear the search.'
: 'Clear the sidebar filter, or create a new room to get started.'}
</div>
</div>
) : showBothSections ? (
<>
{mine.length > 0 && (
<Section label="My rooms" count={mine.length}>
<RoomsTable
rooms={mine}
onEdit={setFormRoom}
onDelete={setDeleteRoom}
onCopy={handleCopy}
copiedId={copiedId}
/>
</Section>
)}
{shared.length > 0 && (
<Section label="Shared rooms" count={shared.length}>
<RoomsTable
rooms={shared}
onEdit={setFormRoom}
onDelete={setDeleteRoom}
onCopy={handleCopy}
copiedId={copiedId}
/>
</Section>
)}
</>
) : (
<Section label={titleFor(filter)} count={filtered.length}>
<RoomsTable
rooms={filtered}
onEdit={setFormRoom}
onDelete={setDeleteRoom}
onCopy={handleCopy}
copiedId={copiedId}
/>
</Section>
)}
</div>
</div>
{formRoom !== null && (
<RoomFormDialog
room={formRoom === 'new' ? null : formRoom}
onClose={() => setFormRoom(null)}
onSave={handleSave}
saving={createRoom.isPending || updateRoom.isPending}
/>
)}
{deleteRoom && (
<DeleteRoomDialog
name={deleteRoom.name}
onClose={() => setDeleteRoom(null)}
onConfirm={() => destroyRoom.mutate(deleteRoom.id)}
loading={destroyRoom.isPending}
/>
)}
{newOpen && <NewTranscriptDialog onClose={() => setNewOpen(false)} />}
</AppShell>
)
}
function Section({
label,
count,
children,
}: {
label: string
count: number
children: React.ReactNode
}) {
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: 10,
padding: '18px 20px 10px',
fontFamily: 'var(--font-sans)',
}}
>
<span
style={{
fontFamily: 'var(--font-serif)',
fontSize: 15,
fontWeight: 600,
color: 'var(--fg)',
letterSpacing: '-0.005em',
}}
>
{label}
</span>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--fg-muted)',
}}
>
{count} {count === 1 ? 'room' : 'rooms'}
</span>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,450 @@
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { AppShell } from '@/components/layout/AppShell'
import { AppSidebar } from '@/components/layout/AppSidebar'
import { NewTranscriptDialog } from '@/components/shared/NewTranscriptDialog'
import { ConfirmDialog } from '@/components/browse/ConfirmDialog'
import { TranscriptHeader } from '@/components/transcript/TranscriptHeader'
import { MetadataStrip } from '@/components/transcript/MetadataStrip'
import { StatusPlaceholder } from '@/components/transcript/StatusPlaceholder'
import {
AudioDeletedBanner,
ErrorBanner,
} from '@/components/transcript/Banners'
import { AudioPlayer } from '@/components/transcript/AudioPlayer'
import { TopicsList } from '@/components/transcript/TopicsList'
import { SummaryPanel } from '@/components/transcript/SummaryPanel'
import { VideoPanel } from '@/components/transcript/VideoPanel'
import { ShareDialog } from '@/components/transcript/ShareDialog'
import { useAuth } from '@/auth/AuthContext'
import { useRooms } from '@/hooks/useRooms'
import {
useTranscript,
useTranscriptMutations,
useTranscriptParticipants,
useTranscriptTopics,
useTranscriptWaveform,
} from '@/hooks/useTranscript'
import { useTranscriptWs } from '@/hooks/useTranscriptWs'
import { messageFor } from '@/lib/apiErrors'
import { buildTranscriptMarkdown } from '@/lib/transcriptMarkdown'
import type { SidebarFilter } from '@/lib/types'
const TERMINAL = new Set(['ended', 'error'])
export function TranscriptPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const { data: rooms = [] } = useRooms()
const [collapsed, setCollapsed] = useState(false)
const [newOpen, setNewOpen] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [seekTarget, setSeekTarget] = useState<{
seconds: number
nonce: number
} | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const [videoOpen, setVideoOpen] = useState(false)
const transcriptQuery = useTranscript(id)
const transcript = transcriptQuery.data
const status = transcript?.status ?? 'idle'
const isTerminal = TERMINAL.has(status)
const audioAvailable = !!transcript && !transcript.audio_deleted && status === 'ended'
const topicsQuery = useTranscriptTopics(id, isTerminal)
const waveformQuery = useTranscriptWaveform(id, audioAvailable)
const participantsQuery = useTranscriptParticipants(id, !!transcript)
const topics = topicsQuery.data ?? []
const participants = participantsQuery.data ?? []
const peaks = waveformQuery.data?.data ?? null
const { update, softDelete, sendEmail, postToZulip } = useTranscriptMutations(id)
useTranscriptWs(id)
const canEdit = useMemo(() => {
if (!transcript) return false
if (!user?.sub) return false
return transcript.user_id === user.sub
}, [transcript, user?.sub])
const speakerCount = transcript?.participants?.length ?? participants.length ?? 0
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({
kind: 'all',
value: null,
})
const onSidebarFilter = (f: SidebarFilter) => {
setSidebarFilter(f)
if (f.kind === 'trash') navigate('/browse?trash=1')
else if (f.kind === 'recent') navigate('/browse?recent=1')
else if (f.kind === 'source') navigate(`/browse?source=${f.value}`)
else if (f.kind === 'room') navigate(`/browse?source=room&room=${f.value}`)
else navigate('/browse')
}
const seekTo = (seconds: number) => {
setSeekTarget({ seconds, nonce: Date.now() })
}
const activeTopicId = useMemo(() => {
if (topics.length === 0) return null
let best = topics[0]
for (const t of topics) {
if ((t.timestamp ?? 0) <= currentTime) best = t
}
return best.id ?? null
}, [topics, currentTime])
const handleRename = async (next: string) => {
try {
await update.mutateAsync({ title: next })
toast.success('Title updated')
} catch (err) {
toast.error(messageFor(err, 'Rename failed'))
throw err
}
}
const handleSummarySave = async (next: string) => {
try {
await update.mutateAsync({ long_summary: next })
toast.success('Summary updated')
} catch (err) {
toast.error(messageFor(err, 'Summary save failed'))
throw err
}
}
const handleCopyMarkdown = async () => {
if (!transcript) return
const md = buildTranscriptMarkdown(transcript, topics, participants)
try {
await navigator.clipboard.writeText(md)
toast.success('Copied transcript as markdown')
} catch {
toast.error('Could not copy to clipboard')
}
}
const handleDownloadZip = async () => {
if (!id) return
try {
const token = sessionStorage.getItem('reflector.password_token') || bearerFromOidc()
const headers: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {}
const res = await fetch(`/v1/transcripts/${id}/download/zip`, { headers })
if (!res.ok) throw new Error(`Download failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `transcript_${id.slice(0, 8)}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Download failed')
}
}
const handleDelete = async () => {
try {
await softDelete.mutateAsync()
toast.success('Moved to trash')
navigate('/browse?trash=1')
} catch (err) {
toast.error(messageFor(err, 'Delete failed'))
}
}
if (!id) {
return <Navigate />
}
if (transcriptQuery.isLoading) {
return (
<AppShell
title="Transcript"
sidebar={
<AppSidebar
filter={sidebarFilter}
onFilter={onSidebarFilter}
rooms={rooms}
tags={[]}
showTags={false}
collapsed={collapsed}
onToggle={() => setCollapsed((v) => !v)}
onNewRecording={() => setNewOpen(true)}
/>
}
>
<div
style={{
padding: 40,
textAlign: 'center',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
Loading transcript
</div>
{newOpen && <NewTranscriptDialog onClose={() => setNewOpen(false)} />}
</AppShell>
)
}
if (transcriptQuery.isError || !transcript) {
const status404 =
(transcriptQuery.error as { status?: number } | null)?.status === 404
return (
<AppShell
title="Transcript"
sidebar={
<AppSidebar
filter={sidebarFilter}
onFilter={onSidebarFilter}
rooms={rooms}
tags={[]}
showTags={false}
collapsed={collapsed}
onToggle={() => setCollapsed((v) => !v)}
onNewRecording={() => setNewOpen(true)}
/>
}
>
<div
style={{
padding: 40,
textAlign: 'center',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
{status404 ? 'Transcript not found.' : 'Failed to load transcript.'}
</div>
{newOpen && <NewTranscriptDialog onClose={() => setNewOpen(false)} />}
</AppShell>
)
}
const topicTimestamps = topics
.map((t) => t.timestamp ?? 0)
.filter((s) => s > 0)
const showVideo =
transcript.has_cloud_video && isTerminal && !!user?.sub && canEdit
return (
<AppShell
title="Transcript"
crumb={['browse', 'detail']}
sidebar={
<AppSidebar
filter={sidebarFilter}
onFilter={onSidebarFilter}
rooms={rooms}
tags={[]}
showTags={false}
collapsed={collapsed}
onToggle={() => setCollapsed((v) => !v)}
onNewRecording={() => setNewOpen(true)}
/>
}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
<div
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xs)',
overflow: 'hidden',
}}
>
<TranscriptHeader
transcript={transcript}
canEdit={canEdit}
canDownload={canEdit}
onRename={handleRename}
onCopyMarkdown={handleCopyMarkdown}
onOpenShare={() => setShareOpen(true)}
onDownloadZip={handleDownloadZip}
onDelete={() => setConfirmDelete(true)}
onToggleVideo={
showVideo ? () => setVideoOpen((v) => !v) : null
}
videoOpen={videoOpen}
/>
<div style={{ padding: '12px 20px 16px' }}>
<MetadataStrip transcript={transcript} speakerCount={speakerCount} />
</div>
</div>
{videoOpen && <VideoPanel transcriptId={transcript.id} enabled={showVideo} />}
{status === 'error' && <ErrorBanner message={null} />}
{!isTerminal ? (
<StatusPlaceholder transcript={transcript} />
) : (
<>
{transcript.audio_deleted ? (
<AudioDeletedBanner />
) : status === 'ended' ? (
<AudioPlayer
transcriptId={transcript.id}
peaks={peaks}
ticks={topicTimestamps}
seekTarget={seekTarget}
onTimeUpdate={(t) => setCurrentTime(t)}
/>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.6fr) minmax(0, 1fr)',
gap: 16,
}}
className="rf-detail-grid"
>
<div
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xs)',
overflow: 'hidden',
}}
>
{topicsQuery.isLoading ? (
<div
style={{
padding: 40,
textAlign: 'center',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
Loading topics
</div>
) : (
<TopicsList
topics={topics}
participants={participants}
activeTopicId={activeTopicId}
currentTime={currentTime}
onSeek={seekTo}
/>
)}
</div>
<SummaryPanel
summary={transcript.long_summary}
canEdit={canEdit}
saving={update.isPending}
onSave={handleSummarySave}
/>
</div>
</>
)}
</div>
{newOpen && <NewTranscriptDialog onClose={() => setNewOpen(false)} />}
{shareOpen && (
<ShareDialog
transcript={transcript}
canEdit={canEdit}
onClose={() => setShareOpen(false)}
onChangeShareMode={async (mode) => {
await update.mutateAsync({ share_mode: mode })
}}
onSendEmail={async (email) => {
await sendEmail.mutateAsync(email)
}}
onPostToZulip={async (stream, topic) => {
await postToZulip.mutateAsync({
stream,
topic,
include_topics: true,
})
}}
/>
)}
{confirmDelete && (
<ConfirmDialog
title="Move to trash?"
message={
<>
<strong style={{ color: 'var(--fg)' }}>
{transcript.title?.trim() || 'Untitled transcript'}
</strong>{' '}
will be moved to the trash. You can restore it later.
</>
}
confirmLabel="Move to trash"
danger
loading={softDelete.isPending}
onConfirm={() => {
setConfirmDelete(false)
void handleDelete()
}}
onClose={() => setConfirmDelete(false)}
/>
)}
</AppShell>
)
}
function Navigate() {
// Defensive: route guard hits this when :id is missing.
return (
<div
style={{
padding: 40,
textAlign: 'center',
color: 'var(--fg-muted)',
fontFamily: 'var(--font-sans)',
}}
>
Missing transcript id.
</div>
)
}
function bearerFromOidc(): string | null {
try {
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 parsed.access_token
} catch {
continue
}
}
} catch {
// ignore
}
return null
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

227
ui/src/styles/greyhaven.css Normal file
View File

@@ -0,0 +1,227 @@
/* =============================================================================
Greyhaven Design System — Colors & Type
Source of truth: imported from greyhaven-design-system/tokens/*.json
Works with plain HTML — no Tailwind required.
============================================================================= */
/* ---------- Fonts ---------- */
/* Aspekta — self-hosted sans (canonical UI typeface) */
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 300; font-display: swap; src: url('./fonts/Aspekta-300.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 400; font-display: swap; src: url('./fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 500; font-display: swap; src: url('./fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 600; font-display: swap; src: url('./fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 700; font-display: swap; src: url('./fonts/Aspekta-700.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-style: normal; font-weight: 800; font-display: swap; src: url('./fonts/Aspekta-800.woff2') format('woff2'); }
/* Source Serif 4 is loaded via <link> in index.html to avoid an @import ordering warning. */
:root {
/* ---------- Color — Primitive ---------- */
--gh-off-white: #F9F9F7;
--gh-off-black: #161614;
--gh-orange: #D95E2A; /* Only accent — used sparingly */
--gh-destructive-red: #B43232;
--gh-grey-1: #F0F0EC; /* 5% — subtle bg, secondary, muted */
--gh-grey-2: #DDDDD7; /* 10% — accent hover, light borders */
--gh-grey-3: #C4C4BD; /* 20% — border, input */
--gh-grey-4: #A6A69F; /* 50% — mid-tone */
--gh-grey-5: #7F7F79; /* 60% — mid-dark */
--gh-grey-7: #575753; /* 70% — secondary/muted foreground */
--gh-grey-8: #2F2F2C; /* 80% — dark-mode card, dark surfaces */
/* ---------- Color — Semantic (Light) ---------- */
--bg: var(--gh-grey-1);
--fg: var(--gh-off-black);
--fg-muted: var(--gh-grey-7);
--card: var(--gh-off-white);
--card-fg: var(--gh-off-black);
--popover: var(--gh-off-white);
--popover-fg: var(--gh-off-black);
--primary: var(--gh-orange);
--primary-fg: var(--gh-off-white);
--secondary: var(--gh-grey-1);
--secondary-fg: var(--gh-grey-8);
--muted: var(--gh-grey-1);
--muted-fg: var(--gh-grey-7);
--accent: var(--gh-grey-2);
--accent-fg: var(--gh-off-black);
--destructive: var(--gh-destructive-red);
--destructive-fg: var(--gh-off-white);
--border: var(--gh-grey-3);
--input: var(--gh-grey-3);
--ring: var(--gh-orange);
--hero-bg: var(--gh-grey-2);
/* Aliases that match common fg1/fg2 shorthand */
--fg1: var(--fg); /* primary text */
--fg2: var(--fg-muted); /* secondary/muted text */
--fg3: var(--gh-grey-5); /* tertiary / disabled-ish */
/* ---------- Typography — Families ---------- */
--font-sans: 'Aspekta', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* ---------- Typography — Scale (rem) ---------- */
--text-xs: 0.75rem; /* 12 — metadata, fine print */
--text-sm: 0.875rem; /* 14 — nav, labels, buttons */
--text-base: 1rem; /* 16 — body */
--text-lg: 1.125rem; /* 18 — large body, subtitles */
--text-xl: 1.25rem; /* 20 — H3 */
--text-2xl: 1.5rem; /* 24 — H2 */
--text-3xl: 1.875rem; /* 30 — large H2 */
--text-4xl: 2.25rem; /* 36 — H1 */
--text-5xl: 3rem; /* 48 — hero heading */
/* Weights */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Leading */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
/* Tracking */
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.05em;
/* ---------- Radii (tight, no playful rounding) ---------- */
--radius-base: 0.375rem; /* 6px */
--radius-sm: calc(0.375rem - 2px); /* 4px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: calc(0.375rem + 2px); /* 8px */
--radius-xl: calc(0.375rem + 4px); /* 10px — cards */
--radius-full: 9999px;
/* ---------- Shadows (subtle, warm-neutral) ---------- */
--shadow-xs: 0 1px 2px 0 rgba(22, 22, 20, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(22, 22, 20, 0.10);
--shadow-md: 0 4px 6px -1px rgba(22, 22, 20, 0.10);
--shadow-lg: 0 10px 15px -3px rgba(22, 22, 20, 0.10);
/* ---------- Spacing ---------- */
--space-0-5: 0.125rem; /* 2 */
--space-1: 0.25rem; /* 4 */
--space-1-5: 0.375rem; /* 6 */
--space-2: 0.5rem; /* 8 */
--space-3: 0.75rem; /* 12 */
--space-4: 1rem; /* 16 */
--space-5: 1.25rem; /* 20 */
--space-6: 1.5rem; /* 24 — card padding */
--space-8: 2rem; /* 32 */
--space-10: 2.5rem; /* 40 — section py */
--space-12: 3rem; /* 48 */
--space-16: 4rem; /* 64 — major sections */
--space-20: 5rem; /* 80 */
--space-24: 6rem; /* 96 — hero padding */
/* Component spacing */
--card-padding: 1.5rem;
--card-gap: 1.5rem;
--section-padding: 2.5rem;
--form-gap: 1rem;
--btn-padding-x: 1rem;
--navbar-h: 4rem;
/* ---------- Motion ---------- */
--dur-fast: 150ms;
--dur-normal: 200ms;
--dur-slow: 300ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
}
/* ---------- Dark theme (applied via .dark class) ---------- */
.dark {
--bg: var(--gh-off-black);
--fg: var(--gh-off-white);
--fg-muted: var(--gh-grey-3);
--card: var(--gh-grey-8);
--card-fg: var(--gh-off-white);
--popover: var(--gh-grey-8);
--popover-fg: var(--gh-off-white);
--primary: var(--gh-orange);
--primary-fg: var(--gh-off-white);
--secondary: var(--gh-grey-7);
--secondary-fg: var(--gh-off-white);
--muted: var(--gh-grey-8);
--muted-fg: var(--gh-grey-3);
--accent: var(--gh-grey-7);
--accent-fg: var(--gh-off-white);
--destructive: var(--gh-destructive-red);
--destructive-fg: var(--gh-off-white);
--border: var(--gh-grey-7);
--input: var(--gh-grey-7);
--ring: var(--gh-orange);
--hero-bg: var(--gh-grey-8);
--fg1: var(--fg);
--fg2: var(--fg-muted);
--fg3: var(--gh-grey-4);
}
/* =============================================================================
Semantic element defaults — opt in with `.gh-prose` or on <body>
============================================================================= */
body.gh, .gh-prose {
font-family: var(--font-serif);
color: var(--fg);
background: var(--bg);
font-size: var(--text-base);
line-height: var(--leading-relaxed);
-webkit-font-smoothing: antialiased;
}
.gh-prose h1, h1.gh {
font-family: var(--font-serif);
font-size: var(--text-4xl);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--fg);
}
.gh-prose h2, h2.gh {
font-family: var(--font-serif);
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--fg);
}
.gh-prose h3, h3.gh {
font-family: var(--font-serif);
font-size: var(--text-xl);
font-weight: var(--weight-medium);
line-height: var(--leading-tight);
color: var(--fg);
}
.gh-prose p, p.gh {
font-family: var(--font-serif);
font-size: var(--text-base);
line-height: var(--leading-relaxed);
color: var(--fg);
}
.gh-prose small, .gh-caption {
font-family: var(--font-sans);
font-size: var(--text-sm);
color: var(--fg-muted);
}
/* UI elements — Aspekta */
.gh-ui, .gh-nav, .gh-button, .gh-label, .gh-metadata {
font-family: var(--font-sans);
}
.gh-nav { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.gh-button{ font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.gh-label { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.gh-metadata { font-size: var(--text-xs); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); }
code, .gh-code { font-family: var(--font-mono); font-size: 0.9em; background: var(--muted); padding: 0.15em 0.35em; border-radius: var(--radius-sm); border: 1px solid var(--border); }

113
ui/src/styles/index.css Normal file
View File

@@ -0,0 +1,113 @@
@import "tailwindcss";
@import "./greyhaven.css";
@import "./reflector-forms.css";
@custom-variant dark (&:where(body.dark, body.dark *));
@theme inline {
--color-bg: var(--bg);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-card: var(--card);
--color-card-fg: var(--card-fg);
--color-popover: var(--popover);
--color-popover-fg: var(--popover-fg);
--color-primary: var(--primary);
--color-primary-fg: var(--primary-fg);
--color-secondary: var(--secondary);
--color-secondary-fg: var(--secondary-fg);
--color-muted: var(--muted);
--color-muted-fg: var(--muted-fg);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
--color-destructive: var(--destructive);
--color-destructive-fg: var(--destructive-fg);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
}
/* Reflector sub-brand accent — orange. Sits on top of Greyhaven semantics so
any utility that points to --primary picks it up. */
:root {
--reflector-accent: #D95E2A;
--reflector-accent-hover: #B34818;
--reflector-accent-fg: #F9F9F7;
--reflector-accent-tint: rgba(217, 94, 42, 0.08);
--reflector-accent-tint2: rgba(217, 94, 42, 0.15);
--primary: var(--reflector-accent);
--primary-fg: var(--reflector-accent-fg);
--ring: var(--reflector-accent);
--status-live: #D95E2A;
--status-ok: #3F9D5A;
--status-processing: #C89B2E;
--status-failed: var(--destructive);
--status-idle: var(--gh-grey-4);
--row-height: 64px;
--row-vpad: 14px;
}
html, body, #root { margin: 0; padding: 0; height: 100%; background: var(--bg); }
body {
font-family: var(--font-sans);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: var(--gh-grey-3); border-radius: 9999px; border: 2px solid var(--bg); }
*::-webkit-scrollbar-thumb:hover { background: var(--gh-grey-4); }
button:focus-visible, a:focus-visible, input:focus-visible, [role="button"]:focus-visible, select:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary) 50%, transparent);
outline-offset: 1px;
}
input:focus { outline: none; }
::selection { background: var(--reflector-accent-tint2); color: var(--fg); }
.rf-row { transition: background-color var(--dur-fast) var(--ease-default); }
.rf-row:hover { background: var(--muted); }
.rf-row .rf-actions { opacity: 0; transition: opacity var(--dur-fast) var(--ease-default); }
.rf-row:hover .rf-actions, .rf-row:focus-within .rf-actions { opacity: 1; }
.rf-row[data-active="true"] { background: var(--accent); }
@keyframes rfSpin { to { transform: rotate(360deg); } }
body.dark aside { background: var(--gh-off-black) !important; }
body.dark aside > div:first-child { border-bottom-color: var(--gh-grey-7); }
.rf-wave { display: flex; align-items: center; gap: 2px; height: 28px; }
.rf-wave span {
display: inline-block; width: 2px; background: currentColor; border-radius: 1px;
opacity: 0.55;
}
.rf-tag {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 7px 1px 6px; height: 18px;
font-family: var(--font-sans); font-size: 10.5px; font-weight: 500;
color: var(--fg-muted); background: var(--muted);
border: 1px solid var(--border); border-radius: 9999px;
white-space: nowrap;
}
.rf-kbd {
font-family: var(--font-mono); font-size: 10.5px;
padding: 1px 5px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--card);
color: var(--fg-muted);
}

View File

@@ -0,0 +1,53 @@
/* Shared form + modal primitives used by Reflector pages.
Keep visuals in sync between home and browse. */
@keyframes rfSpin { to { transform: rotate(360deg); } }
@keyframes rfPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(1.15); }
}
@keyframes rfFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@keyframes rfModalIn {
from { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes rfBackdropIn { from { opacity: 0; } to { opacity: 1; } }
/* Form primitives */
.rf-input, .rf-select {
width: 100%; height: 40px; padding: 0 12px;
font-family: var(--font-sans); font-size: 14px; color: var(--fg);
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-md);
transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
}
.rf-select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23575753' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
background-repeat: no-repeat; background-position: right 12px center;
padding-right: 34px; cursor: pointer;
}
.rf-input::placeholder { color: var(--gh-grey-4); }
.rf-input:hover, .rf-select:hover { border-color: var(--gh-grey-4); }
.rf-input:focus, .rf-select:focus { border-color: var(--primary); outline: none; }
.rf-label {
font-family: var(--font-sans); font-size: 12px; font-weight: 600;
color: var(--fg); letter-spacing: 0.01em;
display: flex; align-items: center; gap: 6px;
}
.rf-hint { font-size: 11.5px; color: var(--fg-muted); margin-top: 2px; }
/* Modal */
.rf-modal-backdrop {
position: fixed; inset: 0; background: color-mix(in oklch, var(--gh-off-black) 55%, transparent);
z-index: 1000; animation: rfBackdropIn var(--dur-normal) var(--ease-default);
}
.rf-modal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 48px);
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
z-index: 1001; display: flex; flex-direction: column;
animation: rfModalIn var(--dur-normal) var(--ease-default);
}

30
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
ui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

24
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const apiTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:1250'
return {
base: '/v2/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 3001,
proxy: {
'/v1': { target: apiTarget, changeOrigin: true },
},
},
}
})