mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-24 22:25:19 +00:00
Compare commits
3 Commits
juan/uiv2
...
release-pl
| Author | SHA1 | Date | |
|---|---|---|---|
| eac8865270 | |||
|
|
52888f692f | ||
|
|
aa7f4cdb39 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -33,9 +33,3 @@ Caddyfile.gpu-host
|
||||
.env.gpu-host
|
||||
vibedocs/
|
||||
server/tests/integration/logs/
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
greyhaven-design-system/
|
||||
.claude/
|
||||
AGENTS.md
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [0.45.1](https://github.com/GreyhavenHQ/reflector/compare/v0.45.0...v0.45.1) (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* past due meetings are now 8h for ics ([#958](https://github.com/GreyhavenHQ/reflector/issues/958)) ([52888f6](https://github.com/GreyhavenHQ/reflector/commit/52888f692fee2c5c06f62e51230b0ecfd54b8814))
|
||||
|
||||
## [0.45.0](https://github.com/GreyhavenHQ/reflector/compare/v0.44.0...v0.45.0) (2026-04-09)
|
||||
|
||||
|
||||
|
||||
48
CLAUDE.md
48
CLAUDE.md
@@ -202,51 +202,3 @@ 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.
|
||||
- The **only** imports allowed to remain inline are from `reflector.db.*` modules (e.g., `reflector.db.transcripts`, `reflector.db.meetings`, `reflector.db.recordings`, `reflector.db.rooms`). These stay as deferred/inline imports inside `fresh_db_connection()` blocks in Hatchet pipeline task functions — this is intentional to avoid sharing DB connections across forked processes. All other imports (utilities, services, processors, storage, third-party libs) **must** go at the top of the file, even in Hatchet workflows.
|
||||
|
||||
|
||||
This project uses the **Greyhaven Design System**.
|
||||
|
||||
## Rules
|
||||
|
||||
- **ALWAYS use TypeScript** (`.tsx` / `.ts`). NEVER generate plain JavaScript (`.jsx` / `.js`).
|
||||
- Use the `greyhaven` SKILL.md for full design system context (tokens, components, composition rules). It should be installed at `.claude/skills/greyhaven-design-system.md` or accessible to your AI tool.
|
||||
- If the `greyhaven` MCP server is available, use its tools:
|
||||
- `list_components()` to find the right component for a UI need
|
||||
- `get_component(name)` to get exact props, variants, and usage examples
|
||||
- `validate_colors(code)` to check code for off-brand colors
|
||||
- `suggest_component(description)` to get recommendations
|
||||
- Import components from `components/ui/` (or `@/components/ui/` with path alias)
|
||||
- Never use raw hex colors -- use semantic Tailwind classes (`bg-primary`, `text-foreground`, `border-border`, etc.)
|
||||
- Use `font-sans` (Aspekta) for UI elements: buttons, nav, labels, forms
|
||||
- Use `font-serif` (Source Serif) for content: headings, body text
|
||||
- Trust the design system's default component variants for accent -- they apply orange at the right scale. Don't apply `bg-primary` to large surfaces, containers, or section backgrounds
|
||||
- All components are framework-agnostic React (no Next.js, no framework-specific imports)
|
||||
- Dark mode is toggled via the `.dark` class -- use semantic tokens that adapt automatically
|
||||
|
||||
## Component Summary
|
||||
|
||||
38 components across 8 categories: primitives (11), layout (4), overlay (5), navigation (3), data (4), feedback (4), form (1), composition (6).
|
||||
|
||||
For full component specs, props, and examples, refer to the SKILL.md file or use the MCP `get_component(name)` tool.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **CVA variants**: Components use `class-variance-authority` for variant props
|
||||
- **Slot composition**: Components use `data-slot="name"` attributes
|
||||
- **Class merging**: Always use `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
||||
- **Focus rings**: `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`
|
||||
- **Disabled**: `disabled:pointer-events-none disabled:opacity-50`
|
||||
- **Card spacing**: `gap-6` between cards, `p-6` internal padding
|
||||
- **Section rhythm**: `py-16` between major sections
|
||||
- **Form layout**: Vertical stack with `gap-4`, labels above inputs
|
||||
|
||||
## Font Setup
|
||||
|
||||
If fonts aren't loaded yet, add to your global CSS:
|
||||
```css
|
||||
@font-face { font-family: 'Aspekta'; font-weight: 400; font-display: swap; src: url('/fonts/Aspekta-400.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-weight: 500; font-display: swap; src: url('/fonts/Aspekta-500.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-weight: 600; font-display: swap; src: url('/fonts/Aspekta-600.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Aspekta'; font-weight: 700; font-display: swap; src: url('/fonts/Aspekta-700.woff2') format('woff2'); }
|
||||
```
|
||||
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
handle /health {
|
||||
reverse_proxy server:1250
|
||||
}
|
||||
handle /v2* {
|
||||
reverse_proxy ui:80
|
||||
}
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
|
||||
@@ -129,23 +129,6 @@ services:
|
||||
depends_on:
|
||||
- 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:
|
||||
image: redis:7.2-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
33
docs/pnpm-lock.yaml
generated
33
docs/pnpm-lock.yaml
generated
@@ -2252,11 +2252,11 @@ packages:
|
||||
resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
brace-expansion@1.1.13:
|
||||
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -3011,9 +3011,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.2:
|
||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||
engines: {node: '>=20'}
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
@@ -3286,8 +3285,8 @@ packages:
|
||||
resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
|
||||
hasBin: true
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -9378,12 +9377,12 @@ snapshots:
|
||||
widest-line: 4.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
|
||||
brace-expansion@1.1.13:
|
||||
brace-expansion@1.1.14:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -10232,7 +10231,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.2:
|
||||
dompurify@3.4.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -10535,7 +10534,7 @@ snapshots:
|
||||
|
||||
flat@5.0.2: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
foreach@2.0.6: {}
|
||||
|
||||
@@ -10892,7 +10891,7 @@ snapshots:
|
||||
http-proxy@1.18.1:
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
follow-redirects: 1.15.11
|
||||
follow-redirects: 1.16.0
|
||||
requires-port: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -11470,7 +11469,7 @@ snapshots:
|
||||
d3-sankey: 0.12.3
|
||||
dagre-d3-es: 7.0.13
|
||||
dayjs: 1.11.19
|
||||
dompurify: 3.3.2
|
||||
dompurify: 3.4.0
|
||||
katex: 0.16.33
|
||||
khroma: 2.1.0
|
||||
lodash-es: 4.17.23
|
||||
@@ -11824,11 +11823,11 @@ snapshots:
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.13
|
||||
brace-expansion: 1.1.14
|
||||
|
||||
minimatch@5.1.8:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.3
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
||||
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.
@@ -1,161 +0,0 @@
|
||||
/*! Aspekta | OFL v1.1 License | Ivo Dolenc (c) 2025 | https://github.com/ivodolenc/aspekta */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 50;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-50.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-100.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 150;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-150.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-200.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 250;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-250.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 350;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-350.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 450;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-450.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 550;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-550.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-650.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 750;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-750.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-800.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-850.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-900.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 950;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-950.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Aspekta';
|
||||
font-style: normal;
|
||||
font-weight: 1000;
|
||||
font-display: swap;
|
||||
src: url('Aspekta-1000.woff2') format('woff2');
|
||||
}
|
||||
@@ -1494,9 +1494,6 @@ $CUSTOM_DOMAIN {
|
||||
}
|
||||
handle /health {
|
||||
reverse_proxy server:1250
|
||||
}
|
||||
handle /v2* {
|
||||
reverse_proxy ui:80
|
||||
}${lk_proxy_block}${hatchet_proxy_block}
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
@@ -1514,9 +1511,6 @@ $CUSTOM_DOMAIN {
|
||||
}
|
||||
handle /health {
|
||||
reverse_proxy server:1250
|
||||
}
|
||||
handle /v2* {
|
||||
reverse_proxy ui:80
|
||||
}${lk_proxy_block}${hatchet_proxy_block}
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
@@ -1538,9 +1532,6 @@ CADDYEOF
|
||||
}
|
||||
handle /health {
|
||||
reverse_proxy server:1250
|
||||
}
|
||||
handle /v2* {
|
||||
reverse_proxy ui:80
|
||||
}${lk_proxy_block}${hatchet_proxy_block}
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
@@ -1581,12 +1572,9 @@ step_services() {
|
||||
info "Building frontend image from source..."
|
||||
compose_cmd build web
|
||||
ok "Frontend image built"
|
||||
info "Building v2 UI image from source..."
|
||||
compose_cmd build ui
|
||||
ok "v2 UI image built"
|
||||
else
|
||||
info "Pulling latest backend and frontend images..."
|
||||
compose_cmd pull server web ui || warn "Pull failed — using cached images"
|
||||
compose_cmd pull server web || warn "Pull failed — using cached images"
|
||||
fi
|
||||
|
||||
# Hatchet is always needed (all processing pipelines use it)
|
||||
@@ -1749,24 +1737,6 @@ step_health() {
|
||||
warn "Frontend not responding. Check: docker compose logs web"
|
||||
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
|
||||
if [[ "$USE_CADDY" == "true" ]]; then
|
||||
sleep 2
|
||||
@@ -2009,25 +1979,20 @@ EOF
|
||||
if [[ "$USE_CADDY" == "true" ]]; then
|
||||
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
||||
echo " App: https://$CUSTOM_DOMAIN"
|
||||
echo " App v2: https://$CUSTOM_DOMAIN/v2/"
|
||||
echo " API: https://$CUSTOM_DOMAIN/v1/"
|
||||
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||
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 " Local: https://localhost"
|
||||
else
|
||||
echo " App: https://localhost (accept self-signed cert in browser)"
|
||||
echo " App v2: https://localhost/v2/"
|
||||
echo " API: https://localhost/v1/"
|
||||
fi
|
||||
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||
echo " App: http://$PRIMARY_IP:3000"
|
||||
echo " App v2: http://$PRIMARY_IP:3001/v2/"
|
||||
echo " API: http://$PRIMARY_IP:1250"
|
||||
else
|
||||
echo " App: http://localhost:3000"
|
||||
echo " App v2: http://localhost:3001/v2/"
|
||||
echo " API: http://localhost:1250"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -175,9 +175,6 @@ class SearchResult(BaseModel):
|
||||
total_match_count: NonNegativeInt = Field(
|
||||
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
|
||||
|
||||
@field_serializer("created_at", when_used="json")
|
||||
@@ -365,7 +362,6 @@ class SearchController:
|
||||
transcripts.c.change_seq,
|
||||
transcripts.c.webvtt,
|
||||
transcripts.c.long_summary,
|
||||
transcripts.c.participants,
|
||||
sqlalchemy.case(
|
||||
(
|
||||
transcripts.c.room_id.isnot(None) & rooms.c.id.is_(None),
|
||||
@@ -462,12 +458,6 @@ class SearchController:
|
||||
long_summary_r: str | None = r_dict.pop("long_summary", None)
|
||||
long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r)
|
||||
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)
|
||||
|
||||
at_least_one_source = webvtt is not None or long_summary is not None
|
||||
@@ -485,7 +475,6 @@ class SearchController:
|
||||
room_name=room_name,
|
||||
search_snippets=snippets,
|
||||
total_match_count=total_match_count,
|
||||
speaker_count=speaker_count,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -446,19 +446,10 @@ class TranscriptController:
|
||||
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(
|
||||
transcript_columns
|
||||
+ [
|
||||
rooms.c.name.label("room_name"),
|
||||
speaker_count_col,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -34,8 +34,7 @@ class AudioTranscriptModalProcessor(AudioTranscriptProcessor):
|
||||
self.transcript_url = settings.TRANSCRIPT_URL + "/v1"
|
||||
self.timeout = settings.TRANSCRIPT_TIMEOUT
|
||||
self.modal_api_key = modal_api_key
|
||||
print(self.timeout, self.modal_api_key)
|
||||
|
||||
|
||||
async def _transcript(self, data: AudioFile):
|
||||
async with AsyncOpenAI(
|
||||
base_url=self.transcript_url,
|
||||
|
||||
@@ -116,7 +116,6 @@ class GetTranscriptMinimal(BaseModel):
|
||||
change_seq: int | None = None
|
||||
has_cloud_video: bool = False
|
||||
cloud_video_duration: int | None = None
|
||||
speaker_count: int = 0
|
||||
|
||||
|
||||
class TranscriptParticipantWithEmail(TranscriptParticipant):
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import logging
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import reflector.auth as auth
|
||||
from reflector.zulip import get_zulip_streams, get_zulip_topics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -26,18 +23,13 @@ async def zulip_get_streams(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
) -> list[Stream]:
|
||||
"""
|
||||
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.
|
||||
Get all Zulip streams.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
|
||||
try:
|
||||
return await get_zulip_streams()
|
||||
except (httpx.HTTPStatusError, httpx.RequestError, Exception) as exc:
|
||||
logger.warning("zulip get_streams failed, returning []: %s", exc)
|
||||
return []
|
||||
streams = await get_zulip_streams()
|
||||
return streams
|
||||
|
||||
|
||||
@router.get("/zulip/streams/{stream_id}/topics")
|
||||
@@ -46,14 +38,10 @@ async def zulip_get_topics(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
) -> list[Topic]:
|
||||
"""
|
||||
Get all topics for a specific Zulip stream. Returns [] on upstream failure
|
||||
for the same reason as /zulip/streams above.
|
||||
Get all topics for a specific Zulip stream.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
|
||||
try:
|
||||
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 []
|
||||
topics = await get_zulip_topics(stream_id)
|
||||
return topics
|
||||
|
||||
@@ -83,9 +83,6 @@ def _should_sync(room) -> bool:
|
||||
return time_since_sync.total_seconds() >= room.ics_fetch_interval
|
||||
|
||||
|
||||
MEETING_DEFAULT_DURATION = timedelta(hours=1)
|
||||
|
||||
|
||||
async def create_upcoming_meetings_for_event(event, create_window, room: Room):
|
||||
if event.start_time <= create_window:
|
||||
return
|
||||
@@ -102,7 +99,9 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room):
|
||||
)
|
||||
|
||||
try:
|
||||
end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
|
||||
# 8h rejoin window matches manual on-the-fly meetings; the scheduled
|
||||
# DTEND stays in calendar_events.end_time for reference.
|
||||
end_date = event.start_time + timedelta(hours=8)
|
||||
|
||||
client = create_platform_client(room.platform)
|
||||
|
||||
|
||||
@@ -5,11 +5,14 @@ import pytest
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
from reflector.db import get_database
|
||||
from reflector.db.calendar_events import calendar_events_controller
|
||||
from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.rooms import rooms, rooms_controller
|
||||
from reflector.services.ics_sync import ics_sync_service
|
||||
from reflector.video_platforms.models import MeetingData
|
||||
from reflector.worker.ics_sync import (
|
||||
_should_sync,
|
||||
create_upcoming_meetings_for_event,
|
||||
sync_room_ics,
|
||||
)
|
||||
|
||||
@@ -225,6 +228,68 @@ async def test_sync_respects_fetch_interval():
|
||||
assert mock_delay.call_args[0][0] == room2.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_upcoming_meeting_uses_8h_end_date():
|
||||
# ICS-pre-created meetings get an 8h rejoin window anchored to the
|
||||
# scheduled start, ignoring the calendar event's DTEND. Regression
|
||||
# guard for the "Meeting has ended" bug when participants run over a
|
||||
# short scheduled window.
|
||||
room = await rooms_controller.add(
|
||||
name="ics-8h-room",
|
||||
user_id="test-user",
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=False,
|
||||
ics_url="https://calendar.example.com/ics-8h.ics",
|
||||
ics_enabled=True,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
event_start = now + timedelta(minutes=1)
|
||||
event_end = event_start + timedelta(minutes=30)
|
||||
|
||||
event = await calendar_events_controller.upsert(
|
||||
CalendarEvent(
|
||||
room_id=room.id,
|
||||
ics_uid="ics-8h-evt",
|
||||
title="Short meeting that runs over",
|
||||
start_time=event_start,
|
||||
end_time=event_end,
|
||||
)
|
||||
)
|
||||
|
||||
create_window = now - timedelta(minutes=6)
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.create_meeting = AsyncMock(
|
||||
return_value=MeetingData(
|
||||
meeting_id="ics-8h-meeting",
|
||||
room_name=room.name,
|
||||
room_url="https://daily.example/ics-8h",
|
||||
host_room_url="https://daily.example/ics-8h",
|
||||
platform=room.platform,
|
||||
extra_data={},
|
||||
)
|
||||
)
|
||||
fake_client.upload_logo = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"reflector.worker.ics_sync.create_platform_client",
|
||||
return_value=fake_client,
|
||||
):
|
||||
await create_upcoming_meetings_for_event(event, create_window, room)
|
||||
|
||||
meeting = await meetings_controller.get_by_calendar_event(event.id, room)
|
||||
assert meeting is not None
|
||||
assert meeting.start_date == event_start
|
||||
assert meeting.end_date == event_start + timedelta(hours=8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_errors_gracefully():
|
||||
room = await rooms_controller.add(
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.git
|
||||
.DS_Store
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
24
ui/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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
87
ui/README.md
@@ -1,87 +0,0 @@
|
||||
# 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)
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,22 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,27 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"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
3536
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 678 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,47 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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)
|
||||
@@ -1,15 +0,0 @@
|
||||
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
4556
ui/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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}</>
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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/')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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),
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { components } from '@/api/schema'
|
||||
import { I } from '@/components/icons'
|
||||
import { Button, SectionLabel, SidebarItem } from '@/components/ui/primitives'
|
||||
import type { RoomsFilter } from '@/lib/types'
|
||||
import { BrandHeader, PrimaryNav, UserChip, sidebarAsideStyle } from './sidebarChrome'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
|
||||
type Room = components['schemas']['RoomDetails']
|
||||
|
||||
type Props = {
|
||||
filter: RoomsFilter
|
||||
onFilter: (f: RoomsFilter) => void
|
||||
rooms: Room[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
onNewRecording?: () => void
|
||||
}
|
||||
|
||||
const PLATFORM_COLOR: Record<Room['platform'], string> = {
|
||||
whereby: 'var(--status-processing)',
|
||||
daily: 'var(--status-ok)',
|
||||
livekit: 'var(--primary)',
|
||||
}
|
||||
|
||||
const PLATFORMS: Room['platform'][] = ['whereby', 'daily', 'livekit']
|
||||
|
||||
export function RoomsSidebar({
|
||||
filter,
|
||||
onFilter,
|
||||
rooms,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onNewRecording,
|
||||
}: Props) {
|
||||
const { user } = useAuth()
|
||||
const isActive = (
|
||||
kind: RoomsFilter['kind'],
|
||||
val: RoomsFilter['value'] | null = null,
|
||||
) => filter.kind === kind && (filter.value ?? null) === val
|
||||
|
||||
const counts = {
|
||||
all: rooms.length,
|
||||
mine: rooms.filter((r) => !r.is_shared).length,
|
||||
shared: rooms.filter((r) => r.is_shared).length,
|
||||
calendar: rooms.filter((r) => r.ics_enabled).length,
|
||||
}
|
||||
|
||||
const platformCount = (p: Room['platform']) =>
|
||||
rooms.filter((r) => r.platform === p).length
|
||||
const sizeCount = (s: string) => rooms.filter((r) => r.room_mode === s).length
|
||||
const recCount = (t: string) => rooms.filter((r) => r.recording_type === t).length
|
||||
|
||||
const presentPlatforms = PLATFORMS.filter((p) => platformCount(p) > 0)
|
||||
|
||||
return (
|
||||
<aside style={sidebarAsideStyle(collapsed) as CSSProperties}>
|
||||
<BrandHeader collapsed={collapsed} onToggle={onToggle} />
|
||||
|
||||
{collapsed ? (
|
||||
<RoomsRail
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
onToggle={onToggle}
|
||||
onNewRecording={onNewRecording}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: '14px 12px 6px' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
onClick={onNewRecording}
|
||||
>
|
||||
{I.Mic(14)} New recording
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 14,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<PrimaryNav />
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
margin: '2px 6px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<SidebarItem
|
||||
icon={I.Door(15)}
|
||||
label="All rooms"
|
||||
count={counts.all}
|
||||
active={isActive('all')}
|
||||
onClick={() => onFilter({ kind: 'all', value: null })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.User(14)}
|
||||
label="My rooms"
|
||||
count={counts.mine}
|
||||
active={isActive('scope', 'mine')}
|
||||
onClick={() => onFilter({ kind: 'scope', value: 'mine' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.Share(14)}
|
||||
label="Shared"
|
||||
count={counts.shared}
|
||||
active={isActive('scope', 'shared')}
|
||||
onClick={() => onFilter({ kind: 'scope', value: 'shared' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>Status</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<SidebarItem
|
||||
icon={I.Radio(14)}
|
||||
label="Active now"
|
||||
dot="var(--status-live)"
|
||||
count={0}
|
||||
active={isActive('status', 'active')}
|
||||
onClick={() => onFilter({ kind: 'status', value: 'active' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.Calendar(14)}
|
||||
label="Calendar-linked"
|
||||
count={counts.calendar}
|
||||
active={isActive('status', 'calendar')}
|
||||
onClick={() => onFilter({ kind: 'status', value: 'calendar' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{presentPlatforms.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>Platform</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{presentPlatforms.map((p) => (
|
||||
<SidebarItem
|
||||
key={p}
|
||||
icon={
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
background: PLATFORM_COLOR[p],
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
count={platformCount(p)}
|
||||
active={isActive('platform', p)}
|
||||
onClick={() => onFilter({ kind: 'platform', value: p })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<SectionLabel>Size</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<SidebarItem
|
||||
icon={I.User(14)}
|
||||
label="2–4 people"
|
||||
count={sizeCount('normal')}
|
||||
active={isActive('size', 'normal')}
|
||||
onClick={() => onFilter({ kind: 'size', value: 'normal' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.Users(14)}
|
||||
label="2–200 people"
|
||||
count={sizeCount('group')}
|
||||
active={isActive('size', 'group')}
|
||||
onClick={() => onFilter({ kind: 'size', value: 'group' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>Recording</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<SidebarItem
|
||||
icon={I.Cloud(14)}
|
||||
label="Cloud"
|
||||
count={recCount('cloud')}
|
||||
active={isActive('recording', 'cloud')}
|
||||
onClick={() => onFilter({ kind: 'recording', value: 'cloud' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.Download(14)}
|
||||
label="Local"
|
||||
count={recCount('local')}
|
||||
active={isActive('recording', 'local')}
|
||||
onClick={() => onFilter({ kind: 'recording', value: 'local' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={I.X(14)}
|
||||
label="None"
|
||||
count={recCount('none')}
|
||||
active={isActive('recording', 'none')}
|
||||
onClick={() => onFilter({ kind: 'recording', value: 'none' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<UserChip user={user} />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
type RailProps = {
|
||||
filter: RoomsFilter
|
||||
onFilter: (f: RoomsFilter) => void
|
||||
onToggle: () => void
|
||||
onNewRecording?: () => void
|
||||
}
|
||||
|
||||
function RoomsRail({ filter, onFilter, onToggle, onNewRecording }: RailProps) {
|
||||
const items: Array<{
|
||||
kind: RoomsFilter['kind']
|
||||
value: RoomsFilter['value'] | null
|
||||
icon: ReturnType<typeof I.Door>
|
||||
title: string
|
||||
}> = [
|
||||
{ kind: 'all', value: null, icon: I.Door(18), title: 'All rooms' },
|
||||
{ kind: 'scope', value: 'mine', icon: I.User(18), title: 'My rooms' },
|
||||
{ kind: 'scope', value: 'shared', icon: I.Share(18), title: 'Shared' },
|
||||
{ kind: 'status', value: 'active', icon: I.Radio(18), title: 'Active' },
|
||||
{ kind: 'status', value: 'calendar', icon: I.Calendar(18), title: 'Calendar' },
|
||||
]
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button variant="primary" size="icon" title="New recording" onClick={onNewRecording}>
|
||||
{I.Mic(16)}
|
||||
</Button>
|
||||
<div style={{ height: 10 }} />
|
||||
{items.map((it, i) => {
|
||||
const on =
|
||||
filter.kind === it.kind && (filter.value ?? null) === (it.value ?? null)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
title={it.title}
|
||||
onClick={() =>
|
||||
onFilter({ kind: it.kind, value: it.value } as RoomsFilter)
|
||||
}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: on ? 'var(--border)' : 'transparent',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: on ? 'var(--card)' : 'transparent',
|
||||
color: on ? 'var(--primary)' : 'var(--fg-muted)',
|
||||
cursor: 'pointer',
|
||||
boxShadow: on ? 'var(--shadow-xs)' : 'none',
|
||||
}}
|
||||
>
|
||||
{it.icon}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
title="Expand sidebar"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{I.ChevronRight(16)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,834 +0,0 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { components } from '@/api/schema'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { I } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/primitives'
|
||||
import { Combobox } from '@/components/ui/Combobox'
|
||||
|
||||
type Room = components['schemas']['RoomDetails']
|
||||
|
||||
export type RoomFormPayload = {
|
||||
name: string
|
||||
platform: 'whereby' | 'daily' | 'livekit'
|
||||
room_mode: string
|
||||
recording_type: string
|
||||
recording_trigger: string
|
||||
is_locked: boolean
|
||||
is_shared: boolean
|
||||
skip_consent: boolean
|
||||
store_video: boolean
|
||||
zulip_auto_post: boolean
|
||||
zulip_stream: string
|
||||
zulip_topic: string
|
||||
webhook_url: string
|
||||
webhook_secret: string
|
||||
ics_url: string | null
|
||||
ics_enabled: boolean
|
||||
ics_fetch_interval: number
|
||||
email_transcript_to: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
room: Room | null
|
||||
onClose: () => void
|
||||
onSave: (payload: RoomFormPayload) => Promise<void>
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const NAME_RE = /^[a-z0-9-_]+$/i
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'calendar', label: 'Calendar' },
|
||||
{ id: 'share', label: 'Share' },
|
||||
{ id: 'webhook', label: 'WebHook' },
|
||||
] as const
|
||||
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
export function RoomFormDialog({ room, onClose, onSave, saving }: Props) {
|
||||
const isEdit = !!room
|
||||
const [tab, setTab] = useState<TabId>('general')
|
||||
|
||||
const [name, setName] = useState(room?.name ?? '')
|
||||
const [platform, setPlatform] = useState<Room['platform']>(room?.platform ?? 'whereby')
|
||||
const [roomMode, setRoomMode] = useState(room?.room_mode ?? 'normal')
|
||||
const [recType, setRecType] = useState(room?.recording_type ?? 'cloud')
|
||||
const [recTrigger, setRecTrigger] = useState(
|
||||
room?.recording_trigger ?? 'automatic-2nd-participant',
|
||||
)
|
||||
const [isLocked, setIsLocked] = useState(room?.is_locked ?? false)
|
||||
const [isShared, setIsShared] = useState(room?.is_shared ?? false)
|
||||
const [skipConsent, setSkipConsent] = useState(room?.skip_consent ?? false)
|
||||
const [storeVideo, setStoreVideo] = useState(room?.store_video ?? false)
|
||||
|
||||
const [icsEnabled, setIcsEnabled] = useState(room?.ics_enabled ?? false)
|
||||
const [icsUrl, setIcsUrl] = useState(room?.ics_url ?? '')
|
||||
const [icsFetchInterval, setIcsFetchInterval] = useState(room?.ics_fetch_interval ?? 5)
|
||||
|
||||
const [zulipAutoPost, setZulipAutoPost] = useState(room?.zulip_auto_post ?? false)
|
||||
const [zulipStream, setZulipStream] = useState(room?.zulip_stream ?? '')
|
||||
const [zulipTopic, setZulipTopic] = useState(room?.zulip_topic ?? '')
|
||||
|
||||
const [webhookUrl, setWebhookUrl] = useState(room?.webhook_url ?? '')
|
||||
const [webhookSecret, setWebhookSecret] = useState(room?.webhook_secret ?? '')
|
||||
|
||||
const [emailTranscriptTo, setEmailTranscriptTo] = useState(room?.email_transcript_to ?? '')
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['config'],
|
||||
queryFn: async () => {
|
||||
const { data, response } = await apiClient.GET('/v1/config')
|
||||
if (!response.ok || !data) throw new Error('Config unavailable')
|
||||
return data
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
const zulipEnabled = configQuery.data?.zulip_enabled ?? false
|
||||
const emailEnabled = configQuery.data?.email_enabled ?? false
|
||||
|
||||
const visibleTabs = TABS.filter((t) => t.id !== 'share' || zulipEnabled)
|
||||
useEffect(() => {
|
||||
if (!visibleTabs.some((t) => t.id === tab)) setTab('general')
|
||||
}, [visibleTabs, tab])
|
||||
|
||||
useEffect(() => {
|
||||
const k = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !saving) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', k)
|
||||
return () => document.removeEventListener('keydown', k)
|
||||
}, [onClose, saving])
|
||||
|
||||
const nameError =
|
||||
!isEdit && name && !NAME_RE.test(name)
|
||||
? 'No spaces or special characters allowed'
|
||||
: ''
|
||||
const canSave = name.trim().length > 0 && !nameError && !saving
|
||||
|
||||
const submit = async () => {
|
||||
setFormError(null)
|
||||
if (!canSave) return
|
||||
try {
|
||||
const effectivePlatform = platform
|
||||
const effectiveRoomMode = effectivePlatform === 'daily' ? 'group' : roomMode
|
||||
const effectiveTrigger =
|
||||
effectivePlatform === 'daily'
|
||||
? recType === 'cloud'
|
||||
? 'automatic-2nd-participant'
|
||||
: 'none'
|
||||
: recTrigger
|
||||
await onSave({
|
||||
name,
|
||||
platform: effectivePlatform,
|
||||
room_mode: effectiveRoomMode,
|
||||
recording_type: recType,
|
||||
recording_trigger: effectiveTrigger,
|
||||
is_locked: isLocked,
|
||||
is_shared: isShared,
|
||||
skip_consent: skipConsent,
|
||||
store_video: storeVideo,
|
||||
zulip_auto_post: zulipAutoPost,
|
||||
zulip_stream: zulipStream,
|
||||
zulip_topic: zulipTopic,
|
||||
webhook_url: webhookUrl,
|
||||
webhook_secret: webhookSecret,
|
||||
ics_url: icsUrl || null,
|
||||
ics_enabled: icsEnabled,
|
||||
ics_fetch_interval: icsFetchInterval,
|
||||
email_transcript_to: emailTranscriptTo || null,
|
||||
})
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rf-modal-backdrop" onClick={() => !saving && onClose()} />
|
||||
<div
|
||||
className="rf-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="rf-room-title"
|
||||
style={{ width: 'min(600px, calc(100vw - 32px))' }}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
style={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<header
|
||||
style={{ padding: '18px 20px 0', display: 'flex', alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2
|
||||
id="rf-room-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-serif)',
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
>
|
||||
{isEdit ? 'Edit room' : 'New room'}
|
||||
</h2>
|
||||
{isEdit && (
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0',
|
||||
fontSize: 12,
|
||||
color: 'var(--fg-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
/{room!.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: 6,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--fg-muted)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{I.X(16)}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
padding: '14px 20px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{visibleTabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '8px 14px 10px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: tab === t.id ? 'var(--fg)' : 'var(--fg-muted)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
borderBottom: '2px solid',
|
||||
borderBottomColor: tab === t.id ? 'var(--primary)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: '12px 20px 0',
|
||||
fontSize: 13,
|
||||
color: 'var(--destructive)',
|
||||
background: 'color-mix(in srgb, var(--destructive) 8%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--destructive) 25%, transparent)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={panelStyle}>
|
||||
{tab === 'general' && (
|
||||
<GeneralTab
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameError={nameError}
|
||||
isEdit={isEdit}
|
||||
platform={platform}
|
||||
setPlatform={setPlatform}
|
||||
isLocked={isLocked}
|
||||
setIsLocked={setIsLocked}
|
||||
roomMode={roomMode}
|
||||
setRoomMode={setRoomMode}
|
||||
recType={recType}
|
||||
setRecType={setRecType}
|
||||
recTrigger={recTrigger}
|
||||
setRecTrigger={setRecTrigger}
|
||||
isShared={isShared}
|
||||
setIsShared={setIsShared}
|
||||
skipConsent={skipConsent}
|
||||
setSkipConsent={setSkipConsent}
|
||||
storeVideo={storeVideo}
|
||||
setStoreVideo={setStoreVideo}
|
||||
emailEnabled={emailEnabled}
|
||||
emailTranscriptTo={emailTranscriptTo}
|
||||
setEmailTranscriptTo={setEmailTranscriptTo}
|
||||
/>
|
||||
)}
|
||||
{tab === 'calendar' && (
|
||||
<CalendarTab
|
||||
icsEnabled={icsEnabled}
|
||||
setIcsEnabled={setIcsEnabled}
|
||||
icsUrl={icsUrl}
|
||||
setIcsUrl={setIcsUrl}
|
||||
icsFetchInterval={icsFetchInterval}
|
||||
setIcsFetchInterval={setIcsFetchInterval}
|
||||
/>
|
||||
)}
|
||||
{tab === 'share' && (
|
||||
<ShareTab
|
||||
zulipEnabled={zulipEnabled}
|
||||
zulipAutoPost={zulipAutoPost}
|
||||
setZulipAutoPost={setZulipAutoPost}
|
||||
zulipStream={zulipStream}
|
||||
setZulipStream={setZulipStream}
|
||||
zulipTopic={zulipTopic}
|
||||
setZulipTopic={setZulipTopic}
|
||||
/>
|
||||
)}
|
||||
{tab === 'webhook' && (
|
||||
<WebhookTab
|
||||
webhookUrl={webhookUrl}
|
||||
setWebhookUrl={setWebhookUrl}
|
||||
webhookSecret={webhookSecret}
|
||||
setWebhookSecret={setWebhookSecret}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{ color: 'var(--fg)', fontWeight: 600 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={!canSave}
|
||||
style={!canSave ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add room'}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Field primitives ---------- */
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
hint,
|
||||
info,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode
|
||||
hint?: ReactNode
|
||||
info?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="rf-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{label}
|
||||
{info && (
|
||||
<span
|
||||
title={info}
|
||||
style={{ display: 'inline-flex', color: 'var(--fg-muted)', cursor: 'help' }}
|
||||
>
|
||||
{I.Info(12)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div style={{ marginTop: 6 }}>{children}</div>
|
||||
{hint && <div className="rf-hint">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
label: ReactNode
|
||||
hint?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
padding: '6px 0',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
border: '1.5px solid',
|
||||
borderColor: checked ? 'var(--primary)' : 'var(--gh-grey-4)',
|
||||
background: checked ? 'var(--primary)' : 'var(--card)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--primary-fg)',
|
||||
transition: 'all var(--dur-fast)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }}
|
||||
/>
|
||||
{checked && I.Check(11)}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--fg)', fontWeight: 500 }}>{label}</span>
|
||||
{hint && (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 2,
|
||||
fontSize: 11.5,
|
||||
color: 'var(--fg-muted)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoBanner({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--fg-muted)',
|
||||
background: 'var(--muted)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--primary)', marginTop: 1, flexShrink: 0 }}>
|
||||
{I.Info(14)}
|
||||
</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
|
||||
type GeneralTabProps = {
|
||||
name: string
|
||||
setName: (v: string) => void
|
||||
nameError: string
|
||||
isEdit: boolean
|
||||
platform: Room['platform']
|
||||
setPlatform: (v: Room['platform']) => void
|
||||
isLocked: boolean
|
||||
setIsLocked: (v: boolean) => void
|
||||
roomMode: string
|
||||
setRoomMode: (v: string) => void
|
||||
recType: string
|
||||
setRecType: (v: string) => void
|
||||
recTrigger: string
|
||||
setRecTrigger: (v: string) => void
|
||||
isShared: boolean
|
||||
setIsShared: (v: boolean) => void
|
||||
skipConsent: boolean
|
||||
setSkipConsent: (v: boolean) => void
|
||||
storeVideo: boolean
|
||||
setStoreVideo: (v: boolean) => void
|
||||
emailEnabled: boolean
|
||||
emailTranscriptTo: string
|
||||
setEmailTranscriptTo: (v: string) => void
|
||||
}
|
||||
|
||||
function GeneralTab(p: GeneralTabProps) {
|
||||
const isDaily = p.platform === 'daily'
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
label="Room name"
|
||||
hint={p.nameError || (!p.isEdit ? 'No spaces or special characters allowed' : undefined)}
|
||||
>
|
||||
<input
|
||||
className="rf-input"
|
||||
type="text"
|
||||
autoFocus={!p.isEdit}
|
||||
disabled={p.isEdit}
|
||||
placeholder="room-name"
|
||||
value={p.name}
|
||||
onChange={(e) => p.setName(e.target.value)}
|
||||
style={p.nameError ? { borderColor: 'var(--destructive)' } : undefined}
|
||||
/>
|
||||
{p.isEdit && (
|
||||
<div className="rf-hint" style={{ color: 'var(--fg-muted)' }}>
|
||||
Room name can't be changed after creation.
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="Platform">
|
||||
<select
|
||||
className="rf-select"
|
||||
value={p.platform}
|
||||
onChange={(e) => p.setPlatform(e.target.value as Room['platform'])}
|
||||
>
|
||||
<option value="whereby">Whereby</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="livekit">LiveKit</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<Checkbox
|
||||
checked={p.isLocked}
|
||||
onChange={p.setIsLocked}
|
||||
label="Locked room"
|
||||
hint="Only the host can admit participants."
|
||||
/>
|
||||
|
||||
{!isDaily && (
|
||||
<FormField label="Room size">
|
||||
<select
|
||||
className="rf-select"
|
||||
value={p.roomMode}
|
||||
onChange={(e) => p.setRoomMode(e.target.value)}
|
||||
>
|
||||
<option value="normal">2–4 people</option>
|
||||
<option value="group">2–200 people</option>
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Recording type"
|
||||
info="Local recording stays on the host's device. Cloud recording uploads to Reflector."
|
||||
>
|
||||
<select
|
||||
className="rf-select"
|
||||
value={p.recType}
|
||||
onChange={(e) => p.setRecType(e.target.value)}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="cloud">Cloud</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{p.recType !== 'none' && !isDaily && (
|
||||
<FormField label="Recording start trigger" info="When should recording begin?">
|
||||
<select
|
||||
className="rf-select"
|
||||
value={p.recTrigger}
|
||||
onChange={(e) => p.setRecTrigger(e.target.value)}
|
||||
>
|
||||
<option value="none">Manual — host starts recording</option>
|
||||
<option value="prompt">Prompt — ask the host to start</option>
|
||||
<option value="automatic-2nd-participant">
|
||||
Automatic — when a second participant joins
|
||||
</option>
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={p.isShared}
|
||||
onChange={p.setIsShared}
|
||||
label="Shared room"
|
||||
hint="Visible to everyone in the workspace."
|
||||
/>
|
||||
<Checkbox
|
||||
checked={p.skipConsent}
|
||||
onChange={p.setSkipConsent}
|
||||
label="Skip consent dialog"
|
||||
hint="When enabled, participants won't be asked for recording consent. Audio will be stored automatically."
|
||||
/>
|
||||
<Checkbox
|
||||
checked={p.storeVideo}
|
||||
onChange={p.setStoreVideo}
|
||||
label="Store video"
|
||||
hint="Keep the video track alongside audio. Increases storage cost."
|
||||
/>
|
||||
|
||||
{p.emailEnabled && (
|
||||
<FormField
|
||||
label="Email transcript to"
|
||||
hint="Receive a copy of each transcript summary at this address."
|
||||
>
|
||||
<input
|
||||
className="rf-input"
|
||||
type="email"
|
||||
placeholder="team@example.com"
|
||||
value={p.emailTranscriptTo}
|
||||
onChange={(e) => p.setEmailTranscriptTo(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type CalendarTabProps = {
|
||||
icsEnabled: boolean
|
||||
setIcsEnabled: (v: boolean) => void
|
||||
icsUrl: string
|
||||
setIcsUrl: (v: string) => void
|
||||
icsFetchInterval: number
|
||||
setIcsFetchInterval: (v: number) => void
|
||||
}
|
||||
|
||||
function CalendarTab(p: CalendarTabProps) {
|
||||
return (
|
||||
<>
|
||||
<InfoBanner>
|
||||
Reflector polls the calendar on the configured interval. Meeting titles from the feed
|
||||
replace the generic "Meeting" label on recordings.
|
||||
</InfoBanner>
|
||||
|
||||
<Checkbox
|
||||
checked={p.icsEnabled}
|
||||
onChange={p.setIcsEnabled}
|
||||
label="Enable calendar sync"
|
||||
hint="Pull meeting titles from an ICS feed (Google Calendar, Outlook, Fastmail, etc.)."
|
||||
/>
|
||||
|
||||
{p.icsEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
label="ICS feed URL"
|
||||
hint="Paste the secret calendar URL from your provider. Keep it private."
|
||||
>
|
||||
<input
|
||||
className="rf-input"
|
||||
type="url"
|
||||
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
|
||||
value={p.icsUrl}
|
||||
onChange={(e) => p.setIcsUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Fetch interval" hint="Minutes between calendar syncs.">
|
||||
<input
|
||||
className="rf-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={p.icsFetchInterval}
|
||||
onChange={(e) => p.setIcsFetchInterval(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ShareTabProps = {
|
||||
zulipEnabled: boolean
|
||||
zulipAutoPost: boolean
|
||||
setZulipAutoPost: (v: boolean) => void
|
||||
zulipStream: string
|
||||
setZulipStream: (v: string) => void
|
||||
zulipTopic: string
|
||||
setZulipTopic: (v: string) => void
|
||||
}
|
||||
|
||||
function ShareTab(p: ShareTabProps) {
|
||||
const { data: streams = [] } = useQuery({
|
||||
queryKey: ['zulip', 'streams'],
|
||||
queryFn: async () => {
|
||||
const { data, response } = await apiClient.GET('/v1/zulip/streams')
|
||||
if (!response.ok || !data) throw new Error('Failed to load Zulip streams')
|
||||
return data
|
||||
},
|
||||
enabled: p.zulipEnabled,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
const selectedStreamId =
|
||||
streams.find((s) => s.name === p.zulipStream)?.stream_id ?? null
|
||||
const { data: topics = [] } = useQuery({
|
||||
queryKey: ['zulip', 'topics', selectedStreamId],
|
||||
queryFn: async () => {
|
||||
if (selectedStreamId == null) return []
|
||||
const { data, response } = await apiClient.GET(
|
||||
'/v1/zulip/streams/{stream_id}/topics',
|
||||
{ params: { path: { stream_id: selectedStreamId } } },
|
||||
)
|
||||
if (!response.ok || !data) throw new Error('Failed to load Zulip topics')
|
||||
return data
|
||||
},
|
||||
enabled: p.zulipEnabled && selectedStreamId != null,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (!p.zulipEnabled) {
|
||||
return (
|
||||
<InfoBanner>
|
||||
Zulip integration isn't configured on this Reflector instance. Set <code>ZULIP_REALM</code>{' '}
|
||||
and related env vars on the server to enable auto-posting transcript summaries.
|
||||
</InfoBanner>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<InfoBanner>
|
||||
Post the transcript summary + link to a Zulip channel when the meeting ends.
|
||||
</InfoBanner>
|
||||
|
||||
<Checkbox
|
||||
checked={p.zulipAutoPost}
|
||||
onChange={p.setZulipAutoPost}
|
||||
label="Auto-post to Zulip"
|
||||
hint="Send a summary message to a Zulip stream and topic after each meeting."
|
||||
/>
|
||||
|
||||
{p.zulipAutoPost && (
|
||||
<>
|
||||
<FormField label="Stream">
|
||||
<Combobox
|
||||
value={p.zulipStream}
|
||||
onChange={(v) => {
|
||||
p.setZulipStream(v)
|
||||
p.setZulipTopic('')
|
||||
}}
|
||||
options={streams.map((s) => s.name)}
|
||||
placeholder="e.g. reflector"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Topic"
|
||||
hint="The topic within the stream where messages will be posted."
|
||||
>
|
||||
<Combobox
|
||||
value={p.zulipTopic}
|
||||
onChange={p.setZulipTopic}
|
||||
options={topics.map((t) => t.name)}
|
||||
placeholder="e.g. Meeting notes"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type WebhookTabProps = {
|
||||
webhookUrl: string
|
||||
setWebhookUrl: (v: string) => void
|
||||
webhookSecret: string
|
||||
setWebhookSecret: (v: string) => void
|
||||
}
|
||||
|
||||
function WebhookTab(p: WebhookTabProps) {
|
||||
return (
|
||||
<>
|
||||
<InfoBanner>
|
||||
Reflector POSTs a JSON payload to your URL on lifecycle events:{' '}
|
||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>meeting.started</code>,{' '}
|
||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>meeting.ended</code>,{' '}
|
||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>transcript.ready</code>.
|
||||
</InfoBanner>
|
||||
|
||||
<FormField
|
||||
label="Webhook URL"
|
||||
hint="HTTPS required. Signed with the webhook secret below."
|
||||
>
|
||||
<input
|
||||
className="rf-input"
|
||||
type="url"
|
||||
placeholder="https://example.com/reflector/webhook"
|
||||
value={p.webhookUrl}
|
||||
onChange={(e) => p.setWebhookUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Webhook secret"
|
||||
hint="Used to sign each payload (HMAC-SHA256) so your receiver can verify it."
|
||||
>
|
||||
<input
|
||||
className="rf-input"
|
||||
type="text"
|
||||
placeholder="whsec_…"
|
||||
value={p.webhookSecret}
|
||||
onChange={(e) => p.setWebhookSecret(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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')}`
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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 {}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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)
|
||||
@@ -1,25 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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}</>
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user