Compare commits

..

2 Commits

Author SHA1 Message Date
Juan
0956647dbc feat: new ui with greyhaven design system 2026-04-23 15:01:05 -05:00
Kevin Guevara
dc428b2042 adding app v2 (#943) 2026-04-09 11:25:19 -05:00
120 changed files with 18978 additions and 9 deletions

6
.gitignore vendored
View File

@@ -33,3 +33,9 @@ Caddyfile.gpu-host
.env.gpu-host .env.gpu-host
vibedocs/ vibedocs/
server/tests/integration/logs/ server/tests/integration/logs/
node_modules
node_modules
greyhaven-design-system/
.claude/
AGENTS.md

View File

@@ -202,3 +202,51 @@ If you need to do any worker/pipeline related work, search for "Pipeline" classe
- Always put imports at the top of the file. Let ruff/pre-commit handle sorting and formatting of imports. - Always put imports at the top of the file. Let ruff/pre-commit handle sorting and formatting of imports.
- The **only** imports allowed to remain inline are from `reflector.db.*` modules (e.g., `reflector.db.transcripts`, `reflector.db.meetings`, `reflector.db.recordings`, `reflector.db.rooms`). These stay as deferred/inline imports inside `fresh_db_connection()` blocks in Hatchet pipeline task functions — this is intentional to avoid sharing DB connections across forked processes. All other imports (utilities, services, processors, storage, third-party libs) **must** go at the top of the file, even in Hatchet workflows. - The **only** imports allowed to remain inline are from `reflector.db.*` modules (e.g., `reflector.db.transcripts`, `reflector.db.meetings`, `reflector.db.recordings`, `reflector.db.rooms`). These stay as deferred/inline imports inside `fresh_db_connection()` blocks in Hatchet pipeline task functions — this is intentional to avoid sharing DB connections across forked processes. All other imports (utilities, services, processors, storage, third-party libs) **must** go at the top of the file, even in Hatchet workflows.
This project uses the **Greyhaven Design System**.
## Rules
- **ALWAYS use TypeScript** (`.tsx` / `.ts`). NEVER generate plain JavaScript (`.jsx` / `.js`).
- Use the `greyhaven` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at `.claude/skills/greyhaven-design-system.md` or accessible to your AI tool.
- If the `greyhaven` MCP server is available, use its tools:
- `list_components()` to find the right component for a UI need
- `get_component(name)` to get exact props, variants, and usage examples
- `validate_colors(code)` to check code for off-brand colors
- `suggest_component(description)` to get recommendations
- Import components from `components/ui/` (or `@/components/ui/` with path alias)
- Never use raw hex colors -- use semantic Tailwind classes (`bg-primary`, `text-foreground`, `border-border`, etc.)
- Use `font-sans` (Aspekta) for UI elements: buttons, nav, labels, forms
- Use `font-serif` (Source Serif) for content: headings, body text
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
- Dark mode is toggled via the `.dark` class -- use semantic tokens that adapt automatically
## Component Summary
38 components across 8 categories: primitives (11), layout (4), overlay (5), navigation (3), data (4), feedback (4), form (1), composition (6).
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP `get_component(name)` tool.
## Key Patterns
- **CVA variants**: Components use `class-variance-authority` for variant props
- **Slot composition**: Components use `data-slot="name"` attributes
- **Class merging**: Always use `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Focus rings**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
- **Disabled**: `disabled:pointer-events-none disabled:opacity-50`
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
- **Section rhythm**: `py-16` between major sections
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
## Font Setup
If fonts aren't loaded yet, add to your global CSS:
```css
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
```

View File

@@ -19,6 +19,9 @@
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
} }
handle /v2* {
reverse_proxy ui:80
}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
} }

View File

@@ -129,6 +129,23 @@ services:
depends_on: depends_on:
- redis - redis
# Reflector v2 UI — Vite SPA served at /v2 behind Caddy.
# Build-time env vars are baked into the bundle; pass VITE_OIDC_* via build args.
ui:
build:
context: ./ui
dockerfile: Dockerfile
args:
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID:-}
VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email}
image: monadicalsas/reflector-ui:latest
restart: unless-stopped
ports:
- "${BIND_HOST:-127.0.0.1}:3001:80"
depends_on:
- server
redis: redis:
image: redis:7.2-alpine image: redis:7.2-alpine
restart: unless-stopped restart: unless-stopped

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

161
public/fonts/font-face.css Normal file
View File

@@ -0,0 +1,161 @@
/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 50;
font-display: swap;
src: url('Aspekta-50.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('Aspekta-100.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 150;
font-display: swap;
src: url('Aspekta-150.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url('Aspekta-200.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 250;
font-display: swap;
src: url('Aspekta-250.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('Aspekta-300.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 350;
font-display: swap;
src: url('Aspekta-350.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('Aspekta-400.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 450;
font-display: swap;
src: url('Aspekta-450.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('Aspekta-500.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 550;
font-display: swap;
src: url('Aspekta-550.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('Aspekta-600.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 650;
font-display: swap;
src: url('Aspekta-650.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('Aspekta-700.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 750;
font-display: swap;
src: url('Aspekta-750.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('Aspekta-800.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 850;
font-display: swap;
src: url('Aspekta-850.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('Aspekta-900.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 950;
font-display: swap;
src: url('Aspekta-950.woff2') format('woff2');
}
@font-face {
font-family: 'Aspekta';
font-style: normal;
font-weight: 1000;
font-display: swap;
src: url('Aspekta-1000.woff2') format('woff2');
}

View File

@@ -1494,6 +1494,9 @@ $CUSTOM_DOMAIN {
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
}
handle /v2* {
reverse_proxy ui:80
}${lk_proxy_block}${hatchet_proxy_block} }${lk_proxy_block}${hatchet_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
@@ -1511,6 +1514,9 @@ $CUSTOM_DOMAIN {
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
}
handle /v2* {
reverse_proxy ui:80
}${lk_proxy_block}${hatchet_proxy_block} }${lk_proxy_block}${hatchet_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
@@ -1532,6 +1538,9 @@ CADDYEOF
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
}
handle /v2* {
reverse_proxy ui:80
}${lk_proxy_block}${hatchet_proxy_block} }${lk_proxy_block}${hatchet_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
@@ -1572,9 +1581,12 @@ step_services() {
info "Building frontend image from source..." info "Building frontend image from source..."
compose_cmd build web compose_cmd build web
ok "Frontend image built" ok "Frontend image built"
info "Building v2 UI image from source..."
compose_cmd build ui
ok "v2 UI image built"
else else
info "Pulling latest backend and frontend images..." info "Pulling latest backend and frontend images..."
compose_cmd pull server web || warn "Pull failed — using cached images" compose_cmd pull server web ui || warn "Pull failed — using cached images"
fi fi
# Hatchet is always needed (all processing pipelines use it) # Hatchet is always needed (all processing pipelines use it)
@@ -1737,6 +1749,24 @@ step_health() {
warn "Frontend not responding. Check: docker compose logs web" warn "Frontend not responding. Check: docker compose logs web"
fi fi
# v2 UI
info "Waiting for v2 UI..."
local ui_ok=false
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/v2/ > /dev/null 2>&1; then
ui_ok=true
break
fi
echo -ne "\r Waiting for v2 UI... ($i/30)"
sleep 3
done
echo ""
if [[ "$ui_ok" == "true" ]]; then
ok "v2 UI healthy"
else
warn "v2 UI not responding. Check: docker compose logs ui"
fi
# Caddy # Caddy
if [[ "$USE_CADDY" == "true" ]]; then if [[ "$USE_CADDY" == "true" ]]; then
sleep 2 sleep 2
@@ -1979,20 +2009,25 @@ EOF
if [[ "$USE_CADDY" == "true" ]]; then if [[ "$USE_CADDY" == "true" ]]; then
if [[ -n "$CUSTOM_DOMAIN" ]]; then if [[ -n "$CUSTOM_DOMAIN" ]]; then
echo " App: https://$CUSTOM_DOMAIN" echo " App: https://$CUSTOM_DOMAIN"
echo " App v2: https://$CUSTOM_DOMAIN/v2/"
echo " API: https://$CUSTOM_DOMAIN/v1/" echo " API: https://$CUSTOM_DOMAIN/v1/"
elif [[ -n "$PRIMARY_IP" ]]; then elif [[ -n "$PRIMARY_IP" ]]; then
echo " App: https://$PRIMARY_IP (accept self-signed cert in browser)" echo " App: https://$PRIMARY_IP (accept self-signed cert in browser)"
echo " App v2: https://$PRIMARY_IP/v2/"
echo " API: https://$PRIMARY_IP/v1/" echo " API: https://$PRIMARY_IP/v1/"
echo " Local: https://localhost" echo " Local: https://localhost"
else else
echo " App: https://localhost (accept self-signed cert in browser)" echo " App: https://localhost (accept self-signed cert in browser)"
echo " App v2: https://localhost/v2/"
echo " API: https://localhost/v1/" echo " API: https://localhost/v1/"
fi fi
elif [[ -n "$PRIMARY_IP" ]]; then elif [[ -n "$PRIMARY_IP" ]]; then
echo " App: http://$PRIMARY_IP:3000" echo " App: http://$PRIMARY_IP:3000"
echo " App v2: http://$PRIMARY_IP:3001/v2/"
echo " API: http://$PRIMARY_IP:1250" echo " API: http://$PRIMARY_IP:1250"
else else
echo " App: http://localhost:3000" echo " App: http://localhost:3000"
echo " App v2: http://localhost:3001/v2/"
echo " API: http://localhost:1250" echo " API: http://localhost:1250"
fi fi
echo "" echo ""

View File

@@ -175,6 +175,9 @@ class SearchResult(BaseModel):
total_match_count: NonNegativeInt = Field( total_match_count: NonNegativeInt = Field(
default=0, description="Total number of matches found in the transcript" default=0, description="Total number of matches found in the transcript"
) )
speaker_count: NonNegativeInt = Field(
default=0, description="Number of distinct speakers in the transcript"
)
change_seq: int | None = None change_seq: int | None = None
@field_serializer("created_at", when_used="json") @field_serializer("created_at", when_used="json")
@@ -362,6 +365,7 @@ class SearchController:
transcripts.c.change_seq, transcripts.c.change_seq,
transcripts.c.webvtt, transcripts.c.webvtt,
transcripts.c.long_summary, transcripts.c.long_summary,
transcripts.c.participants,
sqlalchemy.case( sqlalchemy.case(
( (
transcripts.c.room_id.isnot(None) & rooms.c.id.is_(None), transcripts.c.room_id.isnot(None) & rooms.c.id.is_(None),
@@ -458,6 +462,12 @@ class SearchController:
long_summary_r: str | None = r_dict.pop("long_summary", None) long_summary_r: str | None = r_dict.pop("long_summary", None)
long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r) long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r)
room_name: str | None = r_dict.pop("room_name", None) room_name: str | None = r_dict.pop("room_name", None)
participants_raw = r_dict.pop("participants", None) or []
speaker_count = (
len({p.get("speaker") for p in participants_raw if isinstance(p, dict)})
if isinstance(participants_raw, list)
else 0
)
db_result = SearchResultDB.model_validate(r_dict) db_result = SearchResultDB.model_validate(r_dict)
at_least_one_source = webvtt is not None or long_summary is not None at_least_one_source = webvtt is not None or long_summary is not None
@@ -475,6 +485,7 @@ class SearchController:
room_name=room_name, room_name=room_name,
search_snippets=snippets, search_snippets=snippets,
total_match_count=total_match_count, total_match_count=total_match_count,
speaker_count=speaker_count,
) )
try: try:

View File

@@ -446,10 +446,19 @@ class TranscriptController:
col for col in transcripts.c if col.name not in exclude_columns col for col in transcripts.c if col.name not in exclude_columns
] ]
# Cheap speaker_count via JSON array length on the participants column
# (same column already stored on every transcript, no extra queries).
# COALESCE handles transcripts where participants is NULL.
speaker_count_col = sqlalchemy.func.coalesce(
sqlalchemy.func.json_array_length(transcripts.c.participants),
0,
).label("speaker_count")
query = query.with_only_columns( query = query.with_only_columns(
transcript_columns transcript_columns
+ [ + [
rooms.c.name.label("room_name"), rooms.c.name.label("room_name"),
speaker_count_col,
] ]
) )

View File

@@ -34,6 +34,7 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
self.transcript_url = settings.TRANSCRIPT_URL + "/v1" self.transcript_url = settings.TRANSCRIPT_URL + "/v1"
self.timeout = settings.TRANSCRIPT_TIMEOUT self.timeout = settings.TRANSCRIPT_TIMEOUT
self.modal_api_key = modal_api_key self.modal_api_key = modal_api_key
print(self.timeout, self.modal_api_key)
async def _transcript(self, data: AudioFile): async def _transcript(self, data: AudioFile):
async with AsyncOpenAI( async with AsyncOpenAI(

View File

@@ -116,6 +116,7 @@ class GetTranscriptMinimal(BaseModel):
change_seq: int | None = None change_seq: int | None = None
has_cloud_video: bool = False has_cloud_video: bool = False
cloud_video_duration: int | None = None cloud_video_duration: int | None = None
speaker_count: int = 0
class TranscriptParticipantWithEmail(TranscriptParticipant): class TranscriptParticipantWithEmail(TranscriptParticipant):

View File

@@ -1,11 +1,14 @@
import logging
from typing import Annotated, Optional from typing import Annotated, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import reflector.auth as auth import reflector.auth as auth
from reflector.zulip import get_zulip_streams, get_zulip_topics from reflector.zulip import get_zulip_streams, get_zulip_topics
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -23,13 +26,18 @@ async def zulip_get_streams(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
) -> list[Stream]: ) -> list[Stream]:
""" """
Get all Zulip streams. Get all Zulip streams. Returns [] if the upstream Zulip API is unreachable
or the server credentials are invalid — the client treats Zulip as an
optional integration and renders gracefully without a hard error.
""" """
if not user: if not user:
raise HTTPException(status_code=403, detail="Authentication required") raise HTTPException(status_code=403, detail="Authentication required")
streams = await get_zulip_streams() try:
return streams return await get_zulip_streams()
except (httpx.HTTPStatusError, httpx.RequestError, Exception) as exc:
logger.warning("zulip get_streams failed, returning []: %s", exc)
return []
@router.get("/zulip/streams/{stream_id}/topics") @router.get("/zulip/streams/{stream_id}/topics")
@@ -38,10 +46,14 @@ async def zulip_get_topics(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
) -> list[Topic]: ) -> list[Topic]:
""" """
Get all topics for a specific Zulip stream. Get all topics for a specific Zulip stream. Returns [] on upstream failure
for the same reason as /zulip/streams above.
""" """
if not user: if not user:
raise HTTPException(status_code=403, detail="Authentication required") raise HTTPException(status_code=403, detail="Authentication required")
topics = await get_zulip_topics(stream_id) try:
return topics return await get_zulip_topics(stream_id)
except (httpx.HTTPStatusError, httpx.RequestError, Exception) as exc:
logger.warning("zulip get_topics(%s) failed, returning []: %s", stream_id, exc)
return []

6
ui/.dockerignore Normal file
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>
)
}

Some files were not shown because too many files have changed in this diff Show More