adding app v2 (#943)

This commit is contained in:
Kevin Guevara
2026-04-09 11:25:19 -05:00
committed by GitHub
parent 5cefc39972
commit dc428b2042
93 changed files with 24703 additions and 9 deletions

2
.gitignore vendored
View File

@@ -33,3 +33,5 @@ Caddyfile.gpu-host
.env.gpu-host
vibedocs/
server/tests/integration/logs/
node_modules
node_modules

View File

@@ -2,7 +2,8 @@ services:
server:
build:
context: server
network_mode: host
ports:
- "1250:1250"
volumes:
- ./server/:/app/
- /app/.venv
@@ -10,12 +11,17 @@ services:
- ./server/.env
environment:
ENTRYPOINT: server
DATABASE_URL: postgresql+asyncpg://reflector:reflector@localhost:5432/reflector
REDIS_HOST: localhost
CELERY_BROKER_URL: redis://localhost:6379/1
CELERY_RESULT_BACKEND: redis://localhost:6379/1
HATCHET_CLIENT_SERVER_URL: http://localhost:8889
HATCHET_CLIENT_HOST_PORT: localhost:7078
DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
REDIS_HOST: redis
CELERY_BROKER_URL: redis://redis:6379/1
CELERY_RESULT_BACKEND: redis://redis:6379/1
HATCHET_CLIENT_SERVER_URL: http://hatchet:8888
HATCHET_CLIENT_HOST_PORT: hatchet:7077
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
worker:
build:

View File

@@ -34,7 +34,8 @@ 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,

View File

@@ -361,6 +361,7 @@ export function useTranscriptUploadAudio() {
});
},
onError: (error) => {
console.log(error)
setError(error as Error, "There was an error uploading the audio file");
},
},

30
www/appv2/.env.example Normal file
View File

@@ -0,0 +1,30 @@
# ─── API ──────────────────────────────────────────────────────────────────────
VITE_API_URL=/v1
VITE_WEBSOCKET_URL=auto
# ─── Auth (server-side, used by Express proxy) ───────────────────────────────
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_ISSUER=
AUTHENTIK_REFRESH_TOKEN_URL=
SERVER_API_URL=http://localhost:1250
AUTH_PROVIDER=authentik
# AUTH_PROVIDER=credentials
# ─── Auth Proxy ──────────────────────────────────────────────────────────────
AUTH_PROXY_PORT=3001
AUTH_PROXY_URL=http://localhost:3001
# ─── Features ────────────────────────────────────────────────────────────────
VITE_FEATURE_REQUIRE_LOGIN=true
VITE_FEATURE_PRIVACY=true
VITE_FEATURE_BROWSE=true
VITE_FEATURE_SEND_TO_ZULIP=true
VITE_FEATURE_ROOMS=true
VITE_FEATURE_EMAIL_TRANSCRIPT=false
# ─── Sentry ──────────────────────────────────────────────────────────────────
VITE_SENTRY_DSN=
# ─── Site ────────────────────────────────────────────────────────────────────
VITE_SITE_URL=http://localhost:3000

20
www/appv2/README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/4d85d7fb-26cc-40ae-b70d-3c99d72ec5e8
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

282
www/appv2/dist/assets/index-BSIeQkMT.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
www/appv2/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
<script type="module" crossorigin src="/assets/index-BSIeQkMT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DT0hy75l.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

13
www/appv2/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reflector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
www/appv2/metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Reflector",
"description": "An open-source AI meeting transcription and summarization platform.",
"requestFramePermissions": []
}

6888
www/appv2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
www/appv2/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "reflector-appv2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite --port=3000 --host=0.0.0.0\" \"tsx watch server/index.ts\"",
"dev:client": "vite --port=3000 --host=0.0.0.0",
"dev:server": "tsx watch server/index.ts",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@daily-co/daily-js": "^0.87.0",
"@fontsource/manrope": "^5.2.8",
"@fontsource/newsreader": "^5.2.10",
"@sentry/react": "^10.40.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.21",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.0.4",
"@whereby.com/browser-sdk": "^3.18.21",
"concurrently": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.72.0",
"react-router-dom": "^7.13.2",
"remeda": "^2.33.6",
"uuid": "^13.0.0",
"vite": "^6.2.0",
"wavesurfer.js": "^7.12.5",
"zustand": "^5.0.12"
},
"devDependencies": {
"@sentry/vite-plugin": "^3.0.0",
"@tailwindcss/postcss": "^4.2.2",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

28
www/appv2/server/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Auth constants and helpers — ported from Next.js app/lib/auth.ts
*/
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
// 4 min is 1 min less than default authentik value.
// Assumes authentik won't be set to access tokens < 4 min
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
export const shouldRefreshToken = (accessTokenExpires: number): boolean => {
const timeLeft = accessTokenExpires - Date.now();
return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE;
};
export const LOGIN_REQUIRED_PAGES = [
"/transcripts/[!new]",
"/browse(.*)",
"/rooms(.*)",
];
export const PROTECTED_PAGES = new RegExp(
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
);
export function getLogoutRedirectUrl(pathname: string): string {
const transcriptPagePattern = /^\/transcripts\/[^/]+$/;
return transcriptPagePattern.test(pathname) ? pathname : "/";
}

354
www/appv2/server/index.ts Normal file
View File

@@ -0,0 +1,354 @@
/**
* Minimal Express auth proxy server for Authentik SSO.
*
* Handles:
* - OAuth redirect to Authentik
* - Callback with code exchange
* - Token refresh
* - Credentials-based login (fallback)
* - Session introspection
*/
import express from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import { shouldRefreshToken, REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: process.env.VITE_SITE_URL || "http://localhost:3000",
credentials: true,
}),
);
// ─── Config ──────────────────────────────────────────────────────────────────
const PORT = Number(process.env.AUTH_PROXY_PORT) || 3001;
const SERVER_API_URL =
process.env.SERVER_API_URL || "http://localhost:1250";
const AUTH_PROVIDER = process.env.AUTH_PROVIDER || "authentik";
// Authentik-specific
const AUTHENTIK_CLIENT_ID = process.env.AUTHENTIK_CLIENT_ID || "";
const AUTHENTIK_CLIENT_SECRET = process.env.AUTHENTIK_CLIENT_SECRET || "";
const AUTHENTIK_ISSUER = process.env.AUTHENTIK_ISSUER || "";
const AUTHENTIK_REFRESH_TOKEN_URL =
process.env.AUTHENTIK_REFRESH_TOKEN_URL || "";
// Cookie settings
const COOKIE_NAME = "reflector_session";
const COOKIE_OPTIONS: express.CookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: "/",
};
// ─── Types ───────────────────────────────────────────────────────────────────
interface SessionData {
accessToken: string;
accessTokenExpires: number;
refreshToken?: string;
user: {
id: string;
name?: string | null;
email?: string | null;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function getUserId(accessToken: string): Promise<string | null> {
try {
const response = await fetch(`${SERVER_API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return null;
const userInfo = await response.json();
return userInfo.sub || null;
} catch (error) {
console.error("Error fetching user ID from backend:", error);
return null;
}
}
function getRedirectUri(req: express.Request): string {
const protocol = req.headers["x-forwarded-proto"] || req.protocol;
const host = req.headers["x-forwarded-host"] || req.get("host");
return `${protocol}://${host}/auth/callback`;
}
function encodeSession(session: SessionData): string {
return Buffer.from(JSON.stringify(session)).toString("base64");
}
function decodeSession(cookie: string): SessionData | null {
try {
return JSON.parse(Buffer.from(cookie, "base64").toString("utf-8"));
} catch {
return null;
}
}
// ─── Routes ──────────────────────────────────────────────────────────────────
/**
* GET /auth/login
* Redirects to Authentik authorize endpoint (SSO flow)
*/
app.get("/auth/login", (req, res) => {
if (AUTH_PROVIDER !== "authentik") {
return res
.status(400)
.json({ error: "SSO not configured. Use POST /auth/login instead." });
}
if (!AUTHENTIK_ISSUER || !AUTHENTIK_CLIENT_ID) {
return res.status(500).json({ error: "Authentik not configured" });
}
const redirectUri = getRedirectUri(req);
const authorizeUrl = new URL(
`${AUTHENTIK_ISSUER}/authorize`,
);
authorizeUrl.searchParams.set("client_id", AUTHENTIK_CLIENT_ID);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
authorizeUrl.searchParams.set(
"scope",
"openid email profile offline_access",
);
return res.redirect(authorizeUrl.toString());
});
/**
* GET /auth/callback
* Handles OAuth callback from Authentik — exchanges code for tokens
*/
app.get("/auth/callback", async (req, res) => {
const { code } = req.query;
if (!code || typeof code !== "string") {
return res.status(400).json({ error: "Missing authorization code" });
}
try {
const redirectUri = getRedirectUri(req);
const tokenResponse = await fetch(AUTHENTIK_REFRESH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: AUTHENTIK_CLIENT_ID,
client_secret: AUTHENTIK_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
}).toString(),
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text();
console.error("Token exchange failed:", tokenResponse.status, errorBody);
return res.redirect("/?error=token_exchange_failed");
}
const tokens = await tokenResponse.json();
const accessToken = tokens.access_token;
const expiresIn = tokens.expires_in;
const refreshToken = tokens.refresh_token;
// Resolve user ID from backend
const userId = await getUserId(accessToken);
if (!userId) {
return res.redirect("/?error=user_id_resolution_failed");
}
const session: SessionData = {
accessToken,
accessTokenExpires: Date.now() + expiresIn * 1000,
refreshToken,
user: {
id: userId,
email: tokens.email || null,
name: tokens.name || null,
},
};
res.cookie(COOKIE_NAME, encodeSession(session), COOKIE_OPTIONS);
// Redirect to the app
const frontendUrl = process.env.VITE_SITE_URL || "http://localhost:3000";
return res.redirect(`${frontendUrl}/welcome`);
} catch (error) {
console.error("OAuth callback error:", error);
return res.redirect("/?error=callback_error");
}
});
/**
* POST /auth/login
* Credentials-based login (email + password)
*/
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
try {
const response = await fetch(`${SERVER_API_URL}/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
return res.status(401).json({ error: "Invalid credentials" });
}
const data = await response.json();
const accessToken = data.access_token;
const expiresIn = data.expires_in;
// Resolve user ID from backend
const userId = await getUserId(accessToken);
if (!userId) {
return res.status(500).json({ error: "Could not resolve user ID" });
}
const session: SessionData = {
accessToken,
accessTokenExpires: Date.now() + expiresIn * 1000,
user: {
id: userId,
email,
},
};
res.cookie(COOKIE_NAME, encodeSession(session), COOKIE_OPTIONS);
return res.json({
accessToken: session.accessToken,
accessTokenExpires: session.accessTokenExpires,
user: session.user,
});
} catch (error) {
console.error("Credentials login error:", error);
return res.status(500).json({ error: "Internal server error" });
}
});
/**
* POST /auth/refresh
* Refresh access token using refresh_token (Authentik only)
*/
app.post("/auth/refresh", async (req, res) => {
const cookie = req.cookies[COOKIE_NAME];
const session = cookie ? decodeSession(cookie) : null;
if (!session) {
return res.status(401).json({ error: "No active session" });
}
if (!session.refreshToken) {
return res.status(400).json({ error: "No refresh token available" });
}
try {
const response = await fetch(AUTHENTIK_REFRESH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: AUTHENTIK_CLIENT_ID,
client_secret: AUTHENTIK_CLIENT_SECRET,
refresh_token: session.refreshToken,
}).toString(),
});
if (!response.ok) {
console.error("Token refresh failed:", response.status);
res.clearCookie(COOKIE_NAME);
return res.status(401).json({ error: REFRESH_ACCESS_TOKEN_ERROR });
}
const refreshedTokens = await response.json();
const updatedSession: SessionData = {
...session,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token || session.refreshToken,
};
res.cookie(COOKIE_NAME, encodeSession(updatedSession), COOKIE_OPTIONS);
return res.json({
accessToken: updatedSession.accessToken,
accessTokenExpires: updatedSession.accessTokenExpires,
user: updatedSession.user,
});
} catch (error) {
console.error("Token refresh error:", error);
return res.status(500).json({ error: "Internal server error" });
}
});
/**
* GET /auth/session
* Returns current session info or 401
*/
app.get("/auth/session", (req, res) => {
const cookie = req.cookies[COOKIE_NAME];
const session = cookie ? decodeSession(cookie) : null;
if (!session) {
return res.status(401).json({ status: "unauthenticated" });
}
// Check if token is expired
if (session.accessTokenExpires < Date.now()) {
// If we have a refresh token, indicate refresh is needed
if (session.refreshToken) {
return res.json({
status: "refresh_needed",
user: session.user,
});
}
// No refresh token → session is dead
res.clearCookie(COOKIE_NAME);
return res.status(401).json({ status: "unauthenticated" });
}
return res.json({
status: "authenticated",
accessToken: session.accessToken,
accessTokenExpires: session.accessTokenExpires,
user: session.user,
});
});
/**
* POST /auth/logout
* Clears session cookie
*/
app.post("/auth/logout", (_req, res) => {
res.clearCookie(COOKIE_NAME);
return res.json({ status: "logged_out" });
});
// ─── Start ───────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Auth proxy server running on http://localhost:${PORT}`);
console.log(` AUTH_PROVIDER: ${AUTH_PROVIDER}`);
console.log(` SERVER_API_URL: ${SERVER_API_URL}`);
if (AUTH_PROVIDER === "authentik") {
console.log(` AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER || "(not set)"}`);
}
});

138
www/appv2/src/App.tsx Normal file
View File

@@ -0,0 +1,138 @@
import {
Routes,
Route,
Navigate,
Outlet,
useLocation,
} from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { Sentry } from "./lib/sentry";
import { queryClient } from "./lib/queryClient";
import { AuthProvider, useAuth } from "./lib/AuthProvider";
import { ErrorProvider } from "./lib/errorContext";
import { RecordingConsentProvider } from "./lib/recordingConsentContext";
import { UserEventsProvider } from "./lib/UserEventsProvider";
import { TopNav } from "./components/layout/TopNav";
import { Footer } from "./components/layout/Footer";
import LoginPage from "./pages/LoginPage";
import WelcomePage from "./pages/WelcomePage";
import RoomsPage from "./pages/RoomsPage";
import RoomMeetingPage from "./pages/RoomMeetingPage";
import TranscriptionsPage from "./pages/TranscriptionsPage";
import SingleTranscriptionPage from "./pages/SingleTranscriptionPage";
import SettingsPage from "./pages/SettingsPage";
import WebinarLandingPage from "./pages/WebinarLandingPage";
import AboutPage from "./pages/AboutPage";
import PrivacyPage from "./pages/PrivacyPage";
// Nav items for TopNav
const NAV_LINKS = [
{ label: "Create", href: "/welcome" },
{ label: "Browse", href: "/transcriptions" },
{ label: "Rooms", href: "/rooms" },
{ label: "Settings", href: "/settings" },
];
// Guard: redirect to / if not authenticated
function RequireAuth() {
const auth = useAuth();
if (auth.status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center bg-surface">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
);
}
return auth.status === "authenticated" ? (
<Outlet />
) : (
<Navigate to="/" replace />
);
}
// Layout: TopNav only
function TopNavLayout() {
return (
<div className="min-h-screen flex flex-col bg-surface">
<TopNav links={NAV_LINKS} />
<main className="flex-1 flex flex-col relative">
<Outlet />
</main>
<Footer />
</div>
);
}
// Layout: TopNav + Content
function AppShellLayout() {
return (
<div className="h-screen flex flex-col bg-surface overflow-hidden">
<TopNav links={NAV_LINKS} />
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto flex flex-col relative">
<div className="flex-1 min-h-0 flex flex-col">
<Outlet />
</div>
<Footer />
</main>
</div>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ErrorProvider>
<RecordingConsentProvider>
<UserEventsProvider>
<Sentry.ErrorBoundary
fallback={
<div className="min-h-screen flex items-center justify-center bg-surface text-on-surface">
<p>Something went wrong. Please refresh the page.</p>
</div>
}
>
<Routes>
{/* Public */}
<Route path="/" element={<LoginPage />} />
<Route path="/webinars/:title" element={<WebinarLandingPage />} />
{/* Protected */}
<Route element={<RequireAuth />}>
<Route element={<TopNavLayout />}>
<Route path="/welcome" element={<WelcomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
</Route>
<Route element={<AppShellLayout />}>
<Route path="/rooms" element={<RoomsPage />} />
<Route
path="/transcriptions"
element={<TranscriptionsPage />}
/>
<Route
path="/transcriptions/:id"
element={<SingleTranscriptionPage />}
/>
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* Fullscreen Room Interfaces */}
<Route path="/rooms/:roomName" element={<RoomMeetingPage />} />
<Route path="/rooms/:roomName/:meetingId" element={<RoomMeetingPage />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Sentry.ErrorBoundary>
</UserEventsProvider>
</RecordingConsentProvider>
</ErrorProvider>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,26 @@
import type { components } from "../lib/reflector-api";
import { isArray } from "remeda";
export type ApiError = {
detail?: components["schemas"]["ValidationError"][];
} | null;
// errors as declared on api types is not != as they in reality e.g. detail may be a string
export const printApiError = (error: ApiError) => {
if (!error || !error.detail) {
return null;
}
const detail = error.detail as unknown;
if (isArray(error.detail)) {
return error.detail.map((e) => e.msg).join(", ");
}
if (typeof detail === "string") {
if (detail.length > 0) {
return detail;
}
console.error("Error detail is empty");
return null;
}
console.error("Error detail is not a string or array");
return null;
};

View File

@@ -0,0 +1,84 @@
/**
* WherebyWebinarEmbed — ported from Next.js, restyled with Tailwind.
*
* Renders the Whereby embed web component for webinar rooms.
*/
import { useEffect, useRef, useState } from "react";
import "@whereby.com/browser-sdk/embed";
interface WherebyWebinarEmbedProps {
roomUrl: string;
onLeave?: () => void;
}
export default function WherebyWebinarEmbed({
roomUrl,
onLeave,
}: WherebyWebinarEmbedProps) {
const wherebyRef = useRef<HTMLElement>(null);
const [noticeDismissed, setNoticeDismissed] = useState(
() => !!localStorage.getItem("recording-notice-dismissed"),
);
// Recording notice toast
useEffect(() => {
if (!roomUrl || noticeDismissed) return;
// We'll show notice until dismissed
return () => {};
}, [roomUrl, noticeDismissed]);
const handleDismissNotice = () => {
localStorage.setItem("recording-notice-dismissed", "true");
setNoticeDismissed(true);
};
const handleLeave = () => {
if (onLeave) {
onLeave();
}
};
useEffect(() => {
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave]);
return (
<div className="relative w-screen h-screen">
{/* Recording Notice Banner */}
{roomUrl && !noticeDismissed && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-white/95 backdrop-blur-sm px-5 py-3 rounded-md shadow-lg border border-outline-variant/20 flex items-center gap-4 max-w-md">
<p className="text-sm text-on-surface flex-1">
This webinar is being recorded. By continuing, you agree to our{" "}
<a
href="https://monadical.com/privacy"
className="text-primary underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>
</p>
<button
onClick={handleDismissNotice}
className="text-muted hover:text-on-surface text-lg leading-none"
>
</button>
</div>
)}
{/* @ts-ignore — whereby-embed is a web component */}
<whereby-embed
ref={wherebyRef}
room={roomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
import { Link } from "react-router-dom";
export function Footer() {
return (
<footer className="mt-auto shrink-0 bg-surface-low py-6 px-8 flex flex-col sm:flex-row justify-between items-center gap-4 border-t border-outline-variant/20 z-10 w-full">
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-widest">
© 2024 Reflector Archive
</span>
<div className="flex flex-wrap items-center justify-center gap-6">
<Link to="/about" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Learn more</Link>
<Link to="/privacy" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Privacy policy</Link>
</div>
</footer>
);
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Menu, X } from 'lucide-react';
import { useAuth } from '../../lib/AuthProvider';
interface NavLink {
label: string;
href: string;
}
interface TopNavProps {
links: NavLink[];
}
export const TopNav: React.FC<TopNavProps> = ({ links }) => {
const location = useLocation();
const auth = useAuth();
const user = auth.status === 'authenticated' ? auth.user : null;
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<header className="z-50 bg-surface/85 backdrop-blur-[12px] px-6 md:px-8 py-4 flex items-center justify-between border-b border-outline-variant/10 shrink-0 relative">
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
<button
className="md:hidden mr-1 text-muted hover:text-primary transition-colors"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<img src="https://reflector.monadical.com/reach.svg" alt="Reflector Logo" className="w-6 h-6" />
<span className="font-serif font-bold text-xl text-on-surface">Reflector</span>
</div>
<nav className="hidden md:flex items-center gap-6">
{links.map((link, index) => {
const isActive = location.pathname === link.href || (link.href !== '/' && location.pathname.startsWith(`${link.href}/`));
return (
<React.Fragment key={link.href}>
<Link
to={link.href}
className={`font-sans text-sm transition-colors ${
isActive ? 'text-primary font-semibold' : 'text-on-surface-variant hover:text-on-surface'
}`}
>
{link.label}
</Link>
{index < links.length - 1 && (
<span className="text-outline-variant/60">·</span>
)}
</React.Fragment>
);
})}
</nav>
</div>
<div className="flex items-center gap-5">
<button className="text-muted hover:text-primary transition-colors"><Bell className="w-5 h-5" /></button>
<div className="relative" ref={dropdownRef}>
<div
className="w-8 h-8 rounded-full bg-surface-high flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/20 transition-all select-none"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{user?.name ? (
<span className="font-serif font-bold text-primary">{user.name.charAt(0)}</span>
) : (
<span className="font-serif font-bold text-primary">C</span>
)}
</div>
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-[0_4px_24px_rgba(27,28,20,0.1)] border border-outline-variant/10 py-1 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="px-4 py-3 border-b border-outline-variant/10 mb-1">
<p className="text-sm font-semibold text-on-surface truncate">{user?.name || 'Curator'}</p>
<p className="text-xs text-muted truncate mt-0.5">{user?.email || 'admin@reflector.com'}</p>
</div>
<button
onClick={() => {
setIsDropdownOpen(false);
auth.signOut();
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 hover:text-red-700 transition-colors font-medium flex items-center gap-2"
>
Log out
</button>
</div>
)}
</div>
</div>
{isMobileMenuOpen && (
<div className="absolute top-[100%] left-0 w-full bg-surface border-b border-outline-variant/10 flex flex-col px-6 py-2 md:hidden shadow-lg z-50 animate-in fade-in slide-in-from-top-2 duration-200">
{links.map((link) => {
const isActive = location.pathname === link.href || (link.href !== '/' && location.pathname.startsWith(`${link.href}/`));
return (
<Link
key={link.href}
to={link.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`py-3.5 font-sans text-[0.9375rem] transition-colors ${
isActive ? 'text-primary font-bold' : 'text-on-surface hover:text-primary'
}`}
>
{link.label}
</Link>
);
})}
</div>
)}
</header>
);
};

View File

@@ -0,0 +1,462 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import {
useRoomCreate,
useRoomUpdate,
useRoomGet,
useRoomTestWebhook,
useConfig,
useZulipStreams,
useZulipTopics
} from '../../lib/apiHooks';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { Checkbox } from '../ui/Checkbox';
import { X, Info, Link as LinkIcon, CheckCircle2, AlertCircle, Hexagon, Loader2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
interface AddRoomModalProps {
isOpen: boolean;
onClose: () => void;
editRoomId?: string | null;
}
type FormData = {
name: string;
platform: 'whereby' | 'daily';
roomMode: 'normal' | 'group';
recordingType: 'none' | 'local' | 'cloud';
recordingTrigger: 'none' | 'prompt' | 'automatic-2nd-participant';
isLocked: boolean;
isShared: boolean;
skipConsent: boolean;
enableIcs: boolean;
icsFetchInterval: number;
emailTranscript: boolean;
emailTranscriptTo: string;
postToZulip: boolean;
zulipStream: string;
zulipTopic: string;
webhookUrl: string;
webhookSecret: string;
};
export function AddRoomModal({ isOpen, onClose, editRoomId }: AddRoomModalProps) {
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'sharing' | 'webhooks'>('general');
const [testResult, setTestResult] = useState<{ status: 'success'|'error', msg: string } | null>(null);
const [isTesting, setIsTesting] = useState(false);
const queryClient = useQueryClient();
const createRoom = useRoomCreate();
const updateRoom = useRoomUpdate();
const testWebhook = useRoomTestWebhook();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const { data: streams = [] } = useZulipStreams(zulipEnabled);
const { data: editedRoom, isFetching: isFetchingRoom } = useRoomGet(editRoomId || null);
const { register, handleSubmit, watch, reset, setValue, formState: { errors } } = useForm<FormData>({
defaultValues: {
name: '',
platform: 'whereby',
roomMode: 'normal',
recordingType: 'cloud',
recordingTrigger: 'automatic-2nd-participant',
isShared: true,
isLocked: false,
skipConsent: false,
enableIcs: false,
icsFetchInterval: 5,
emailTranscript: false,
emailTranscriptTo: '',
postToZulip: false,
zulipStream: '',
zulipTopic: '',
webhookUrl: '',
webhookSecret: '',
}
});
const platform = watch('platform');
const postToZulip = watch('postToZulip');
const webhookUrl = watch('webhookUrl');
const recordingType = watch('recordingType');
const selectedZulipStream = watch('zulipStream');
const emailTranscript = watch('emailTranscript');
// Dynamically resolve zulip stream IDs to query topics
const selectedStreamId = React.useMemo(() => {
if (!selectedZulipStream || streams.length === 0) return null;
const match = streams.find(s => s.name === selectedZulipStream);
return match ? match.stream_id : null;
}, [selectedZulipStream, streams]);
const { data: topics = [] } = useZulipTopics(selectedStreamId);
useEffect(() => {
if (isOpen) {
if (editRoomId && editedRoom) {
// Load Edit Mode
reset({
name: editedRoom.name,
platform: editedRoom.platform as 'whereby' | 'daily',
roomMode: editedRoom.platform === 'daily' ? 'group' : (editedRoom.room_mode || 'normal') as 'normal'|'group',
recordingType: (editedRoom.recording_type || 'none') as 'none'|'local'|'cloud',
recordingTrigger: editedRoom.platform === 'daily'
? (editedRoom.recording_type === 'cloud' ? 'automatic-2nd-participant' : 'none')
: (editedRoom.recording_trigger || 'none') as any,
isShared: editedRoom.is_shared,
isLocked: editedRoom.is_locked,
skipConsent: editedRoom.skip_consent,
enableIcs: editedRoom.ics_enabled || false,
icsFetchInterval: editedRoom.ics_fetch_interval || 5,
emailTranscript: !!editedRoom.email_transcript_to,
emailTranscriptTo: editedRoom.email_transcript_to || '',
postToZulip: editedRoom.zulip_auto_post || false,
zulipStream: editedRoom.zulip_stream || '',
zulipTopic: editedRoom.zulip_topic || '',
webhookUrl: editedRoom.webhook_url || '',
webhookSecret: editedRoom.webhook_secret || '',
});
} else if (!editRoomId) {
// Load Create Mode with specific backend defaults
reset({
name: '',
platform: 'whereby',
roomMode: 'normal',
recordingType: 'cloud',
recordingTrigger: 'automatic-2nd-participant',
isShared: false,
isLocked: false,
skipConsent: false,
enableIcs: false,
icsFetchInterval: 5,
emailTranscript: false,
emailTranscriptTo: '',
postToZulip: false,
zulipStream: '',
zulipTopic: '',
webhookUrl: '',
webhookSecret: '',
});
}
}
}, [isOpen, editRoomId, editedRoom, reset]);
// Handle rigid Platform dependency enums
useEffect(() => {
if (platform === 'daily') {
setValue('roomMode', 'group');
if (recordingType === 'cloud') {
setValue('recordingTrigger', 'automatic-2nd-participant');
} else {
setValue('recordingTrigger', 'none');
}
} else if (platform === 'whereby') {
if (recordingType !== 'cloud') {
setValue('recordingTrigger', 'none');
}
}
}, [platform, recordingType, setValue]);
const handleClose = () => {
reset();
setActiveTab('general');
setTestResult(null);
onClose();
};
const executeWebhookTest = async () => {
if (!webhookUrl || !editRoomId) return;
setIsTesting(true);
setTestResult(null);
try {
const resp = await testWebhook.mutateAsync({
params: { path: { room_id: editRoomId } }
});
if (resp.success) {
setTestResult({ status: 'success', msg: `Test successful! Status: ${resp.status_code}` });
} else {
let err = `Failed (${resp.status_code})`;
if (resp.response_preview) {
try {
const json = JSON.parse(resp.response_preview);
err += `: ${json.message || resp.response_preview}`;
} catch {
err += `: ${resp.response_preview.substring(0, 100)}`;
}
}
setTestResult({ status: 'error', msg: err });
}
} catch {
setTestResult({ status: 'error', msg: "Network failed attempting to test URL." });
} finally {
setIsTesting(false);
}
};
const onSubmit = (data: FormData) => {
const payload = {
name: data.name.replace(/[^a-zA-Z0-9\s-]/g, "").replace(/\s+/g, "-").toLowerCase(),
platform: data.platform,
zulip_auto_post: data.postToZulip,
zulip_stream: data.zulipStream,
zulip_topic: data.zulipTopic,
is_locked: data.isLocked,
room_mode: data.platform === 'daily' ? 'group' : data.roomMode,
recording_type: data.recordingType,
recording_trigger: data.platform === 'daily' ? (data.recordingType === 'cloud' ? 'automatic-2nd-participant' : 'none') : data.recordingTrigger,
is_shared: data.isShared,
webhook_url: data.webhookUrl,
webhook_secret: data.webhookSecret,
ics_url: '',
ics_enabled: data.enableIcs,
ics_fetch_interval: data.icsFetchInterval,
skip_consent: data.skipConsent,
email_transcript_to: data.emailTranscript ? data.emailTranscriptTo : null,
};
if (editRoomId) {
updateRoom.mutate({
params: { path: { room_id: editRoomId } },
body: payload as any
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] });
handleClose();
}
});
} else {
createRoom.mutate({
body: payload as any
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] });
handleClose();
}
});
}
};
if (!isOpen) return null;
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'calendar', label: 'Calendar' },
...(zulipEnabled || emailEnabled ? [{ id: 'sharing', label: 'Sharing' }] : []),
{ id: 'webhooks', label: 'WebHooks' },
] as const;
return (
<div className="fixed inset-0 bg-[#1b1c14]/45 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-[12px] shadow-[0_16px_48px_rgba(27,28,20,0.12)] w-[500px] max-w-full flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="pt-6 px-6 pb-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<Hexagon className="w-5 h-5 text-primary fill-primary/20" />
<h2 className="font-serif text-lg font-bold text-on-surface">
{editRoomId ? 'Edit Room' : 'New Room'}
</h2>
{isFetchingRoom && <Loader2 className="w-4 h-4 animate-spin text-muted ml-2" />}
</div>
<button onClick={handleClose} className="text-muted hover:text-primary hover:bg-primary/10 p-1.5 rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Bar */}
<div className="px-6 mt-4 flex items-center gap-6 relative">
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-surface-high"></div>
{tabs.map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id as any)}
className={`pb-3 font-sans text-sm transition-colors relative z-10 ${
activeTab === tab.id
? 'text-primary font-semibold border-b-[2.5px] border-primary'
: 'text-muted font-medium hover:text-on-surface-variant'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="p-5 px-6 max-h-[60vh] overflow-y-auto">
<form id="add-room-form" onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{activeTab === 'general' && (
<div className="space-y-5 animate-in fade-in duration-300">
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Room Name</label>
<Input
{...register('name', { required: true })}
placeholder="e.g. editorial-sync"
className="w-full"
disabled={!!editRoomId}
/>
<p className="text-xs text-muted mt-1">No spaces allowed. E.g. my-room</p>
</div>
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Platform</label>
<Select {...register('platform')} className="w-full">
<option value="whereby">Whereby</option>
<option value="daily">Daily.co</option>
</Select>
</div>
<div className="space-y-3 pt-2">
<Checkbox {...register('isLocked')} label="Locked room (Require password)" />
</div>
{platform !== 'daily' && (
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Room Size</label>
<Select {...register('roomMode')} className="w-full">
<option value="normal">2-4 people</option>
<option value="group">2-200 people</option>
</Select>
</div>
)}
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 flex items-center gap-1.5">
Recording Type
<Info className="w-3.5 h-3.5 text-muted hover:text-primary transition-colors cursor-help" />
</label>
<Select {...register('recordingType')} className="w-full">
<option value="none">None</option>
<option value="local">Local</option>
<option value="cloud">Cloud</option>
</Select>
</div>
{recordingType === 'cloud' && platform !== 'daily' && (
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Recording Trigger</label>
<Select {...register('recordingTrigger')} className="w-full">
<option value="none">None (Manual)</option>
<option value="prompt">Prompt on Join</option>
<option value="automatic-2nd-participant">Automatic on 2nd Participant</option>
</Select>
</div>
)}
<div className="space-y-3 pt-2 border-t border-outline-variant/10">
<Checkbox {...register('isShared')} label="Shared room (Public archive)" />
<Checkbox {...register('skipConsent')} label="Skip consent checkbox" />
</div>
</div>
)}
{activeTab === 'calendar' && (
<div className="space-y-2 animate-in fade-in duration-300">
<Checkbox {...register('enableIcs')} label="Enable ICS calendar sync" />
<p className="font-sans text-sm text-muted ml-6">When enabled, a calendar feed URL will be generated.</p>
</div>
)}
{activeTab === 'sharing' && (
<div className="space-y-4 animate-in fade-in duration-300">
{emailEnabled && (
<div className="space-y-2 pb-4 border-b border-outline-variant/10">
<Checkbox {...register('emailTranscript')} label="Email transcript functionality" />
{emailTranscript && (
<div className="pl-6 animate-in slide-in-from-top-2">
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Email Address</label>
<Input type="email" {...register('emailTranscriptTo')} placeholder="editor@nyt.com" className="w-full" />
</div>
)}
</div>
)}
{zulipEnabled && (
<div className="space-y-2">
<Checkbox {...register('postToZulip')} label="Automatically post transcription to Zulip" />
<div className={`overflow-hidden transition-all duration-300 ${postToZulip ? 'max-h-48 opacity-100 mt-4' : 'max-h-0 opacity-0'}`}>
<div className="pl-6 space-y-4 border-l-2 border-surface-high ml-2 py-1">
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Zulip stream</label>
<Select {...register('zulipStream')} disabled={!postToZulip} className="w-full">
<option value="">Select stream...</option>
{streams.map(s => <option key={s.stream_id} value={s.name}>{s.name}</option>)}
</Select>
</div>
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Zulip topic</label>
<Select {...register('zulipTopic')} disabled={!postToZulip} className="w-full">
<option value="">Select topic...</option>
{topics.map(t => <option key={t.name} value={t.name}>{t.name}</option>)}
</Select>
</div>
</div>
</div>
</div>
)}
</div>
)}
{activeTab === 'webhooks' && (
<div className="space-y-4 animate-in fade-in duration-300">
<div>
<label className="font-sans text-[0.75rem] font-bold uppercase tracking-widest text-muted mb-1.5 block">Webhook URL</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<LinkIcon className="w-4 h-4 text-muted" />
</div>
<Input
{...register('webhookUrl', { pattern: { value: /^https?:\/\/.+/, message: 'Must be a valid URL starting with http:// or https://' }})}
placeholder="https://example.com/webhook"
className="w-full pl-9 pr-9"
/>
</div>
{errors.webhookUrl && <p className="font-sans text-[0.75rem] text-primary mt-1.5">{errors.webhookUrl.message}</p>}
</div>
{webhookUrl && editRoomId && (
<div className="pt-2 border-t border-shell">
<Button
type="button"
variant="secondary"
onClick={executeWebhookTest}
disabled={isTesting}
className="mb-3"
>
{isTesting ? 'Testing...' : 'Test Webhook Settings'}
</Button>
{testResult && (
<div className={`p-3 rounded-lg text-sm border font-mono ${testResult.status === 'success' ? 'bg-green-50 text-green-800 border-green-200' : 'bg-red-50 text-red-800 border-red-200'}`}>
{testResult.msg}
</div>
)}
</div>
)}
</div>
)}
</form>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-surface-low rounded-b-[12px] flex items-center justify-between border-t border-outline-variant/10">
<Button variant="secondary" onClick={handleClose} className="border-[1.5px] border-[#C8C8BE] text-on-surface-variant hover:bg-surface-high">
Cancel
</Button>
<Button variant="primary" type="submit" form="add-room-form" disabled={createRoom.isPending || updateRoom.isPending}>
{createRoom.isPending || updateRoom.isPending ? 'Saving...' : 'Save Room'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import DailyIframe, {
DailyCall,
DailyCallOptions,
DailyCustomTrayButton,
DailyCustomTrayButtons,
DailyEventObjectCustomButtonClick,
DailyFactoryOptions,
DailyParticipantsObject,
} from '@daily-co/daily-js';
import type { components } from '../../lib/reflector-api';
import { useAuth } from '../../lib/AuthProvider';
import { useConsentDialog } from '../../lib/consent';
import { featureEnabled } from '../../lib/features';
import { useRoomJoinMeeting, useMeetingStartRecording } from '../../lib/apiHooks';
import { omit } from 'remeda';
import { NonEmptyString } from '../../lib/utils';
import { assertMeetingId, DailyRecordingType } from '../../lib/types';
import { v5 as uuidv5 } from 'uuid';
const CONSENT_BUTTON_ID = 'recording-consent';
const RECORDING_INDICATOR_ID = 'recording-indicator';
const RAW_TRACKS_NAMESPACE = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const RECORDING_START_DELAY_MS = 2000;
const RECORDING_START_MAX_RETRIES = 5;
type Meeting = components['schemas']['Meeting'];
type Room = components['schemas']['RoomDetails'];
type MeetingId = string;
type DailyRoomProps = {
meeting: Meeting;
room: Room;
};
const useCustomTrayButtons = (
frame: { updateCustomTrayButtons: (buttons: DailyCustomTrayButtons) => void; joined: boolean } | null
) => {
const [, setCustomTrayButtons] = useState<DailyCustomTrayButtons>({});
return useCallback(
(id: string, button: DailyCustomTrayButton | null) => {
setCustomTrayButtons((prev) => {
const state = button === null ? omit(prev, [id]) : { ...prev, [id]: button };
if (frame !== null && frame.joined) frame.updateCustomTrayButtons(state);
return state;
});
},
[frame]
);
};
const USE_FRAME_INIT_STATE = { frame: null as DailyCall | null, joined: false as boolean } as const;
const useFrame = (
container: HTMLDivElement | null,
cbs: {
onLeftMeeting: () => void;
onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void;
onJoinMeeting: () => void;
}
) => {
const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE);
const setJoined = useCallback((j: boolean) => setState((prev) => ({ ...prev, joined: j })), [setState]);
const setFrame = useCallback((f: DailyCall | null) => setState((prev) => ({ ...prev, frame: f })), [setState]);
useEffect(() => {
if (!container) return;
let isActive = true;
const init = async () => {
const existingFrame = DailyIframe.getCallInstance();
if (existingFrame) {
await existingFrame.destroy();
}
if (!isActive) return;
const frameOptions: DailyFactoryOptions = {
iframeStyle: {
width: '100vw',
height: '100vh',
border: 'none',
},
showLeaveButton: true,
showFullscreenButton: true,
};
const newFrame = DailyIframe.createFrame(container, frameOptions);
setFrame(newFrame);
};
init().catch(console.error);
return () => {
isActive = false;
frame?.destroy().catch(console.error);
setState(USE_FRAME_INIT_STATE);
};
}, [container]);
useEffect(() => {
if (!frame) return;
frame.on('left-meeting', cbs.onLeftMeeting);
frame.on('custom-button-click', cbs.onCustomButtonClick);
const joinCb = () => {
if (!frame) return;
cbs.onJoinMeeting();
};
frame.on('joined-meeting', joinCb);
return () => {
frame.off('left-meeting', cbs.onLeftMeeting);
frame.off('custom-button-click', cbs.onCustomButtonClick);
frame.off('joined-meeting', joinCb);
};
}, [frame, cbs]);
const frame_ = useMemo(() => {
if (frame === null) return frame;
return {
join: async (properties?: DailyCallOptions): Promise<DailyParticipantsObject | void> => {
await frame.join(properties);
setJoined(!frame.isDestroyed());
},
updateCustomTrayButtons: (buttons: DailyCustomTrayButtons): DailyCall => frame.updateCustomTrayButtons(buttons),
};
}, [frame, setJoined]);
const setCustomTrayButton = useCustomTrayButtons(
useMemo(() => (frame_ === null ? null : { updateCustomTrayButtons: frame_.updateCustomTrayButtons, joined }), [
frame_,
joined,
])
);
return [frame_, { setCustomTrayButton }] as const;
};
export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const navigate = useNavigate();
const { roomName } = useParams();
const auth = useAuth();
const authLastUserId = auth.status === 'authenticated' ? auth.user.id : undefined;
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const joinMutation = useRoomJoinMeeting();
const startRecordingMutation = useMeetingStartRecording();
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
const cloudInstanceId = meeting.id;
const rawTracksInstanceId = uuidv5(meeting.id, RAW_TRACKS_NAMESPACE);
const { showConsentModal, showRecordingIndicator, showConsentButton } = useConsentDialog({
meetingId: assertMeetingId(meeting.id),
recordingType: meeting.recording_type,
skipConsent: room.skip_consent,
});
const showConsentModalRef = useRef(showConsentModal);
showConsentModalRef.current = showConsentModal;
useEffect(() => {
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
let isMounted = true;
const join = async () => {
try {
const result = await joinMutation.mutateAsync({
params: { path: { room_name: roomName, meeting_id: meeting.id } },
});
if (isMounted) setJoinedMeeting(result);
} catch (error) {
console.error('Failed to join meeting:', error);
}
};
join().catch(console.error);
return () => { isMounted = false; };
}, [meeting?.id, roomName, authLastUserId]);
const roomUrl = joinedMeeting?.room_url;
const handleLeave = useCallback(() => {
navigate('/transcriptions');
}, [navigate]);
const handleCustomButtonClick = useCallback((ev: DailyEventObjectCustomButtonClick) => {
if (ev.button_id === CONSENT_BUTTON_ID) {
showConsentModalRef.current();
}
}, []);
const handleFrameJoinMeeting = useCallback(() => {
if (meeting.recording_type === 'cloud') {
const startRecordingWithRetry = (type: DailyRecordingType, instanceId: string, attempt: number = 1) => {
setTimeout(() => {
startRecordingMutation.mutate(
{
params: { path: { meeting_id: meeting.id as any } },
body: { type: type as any, instanceId }
},
{
onError: (error: any) => {
const errorText = error?.detail || error?.message || '';
const is404NotHosting = errorText.includes('does not seem to be hosting a call');
const isActiveStream = errorText.includes('has an active stream');
if (is404NotHosting && attempt < RECORDING_START_MAX_RETRIES) {
startRecordingWithRetry(type, instanceId, attempt + 1);
} else if (!isActiveStream) {
console.error(`Failed to start ${type} recording:`, error);
}
},
}
);
}, RECORDING_START_DELAY_MS);
};
startRecordingWithRetry('cloud', cloudInstanceId);
startRecordingWithRetry('raw-tracks', rawTracksInstanceId);
}
}, [meeting.recording_type, meeting.id, cloudInstanceId, rawTracksInstanceId, startRecordingMutation]);
const recordingIconUrl = useMemo(() => new URL('/recording-icon.svg', window.location.origin), []);
const [frame, { setCustomTrayButton }] = useFrame(container, {
onLeftMeeting: handleLeave,
onCustomButtonClick: handleCustomButtonClick,
onJoinMeeting: handleFrameJoinMeeting,
});
useEffect(() => {
if (!frame || !roomUrl) return;
frame.join({
url: roomUrl,
sendSettings: {
video: { allowAdaptiveLayers: true, maxQuality: 'medium' },
},
}).catch(console.error);
}, [frame, roomUrl]);
useEffect(() => {
setCustomTrayButton(
RECORDING_INDICATOR_ID,
showRecordingIndicator
? { iconPath: recordingIconUrl.href, label: 'Recording', tooltip: 'Recording in progress' }
: null
);
}, [showRecordingIndicator, recordingIconUrl, setCustomTrayButton]);
useEffect(() => {
setCustomTrayButton(
CONSENT_BUTTON_ID,
showConsentButton
? { iconPath: recordingIconUrl.href, label: 'Recording (click to consent)', tooltip: 'Recording (click to consent)' }
: null
);
}, [showConsentButton, recordingIconUrl, setCustomTrayButton]);
if (authLastUserId === undefined || joinMutation.isPending) {
return (
<div className="w-screen h-screen flex justify-center items-center bg-surface">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
);
}
if (joinMutation.isError) {
return (
<div className="w-screen h-screen flex justify-center items-center bg-surface">
<p className="text-red-500 font-medium">Failed to join meeting. Please try again.</p>
</div>
);
}
if (!roomUrl) return null;
return (
<div className="relative w-screen h-screen">
<div ref={setContainer} style={{ width: '100%', height: '100%' }} />
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button } from '../ui/Button';
import { Hexagon } from 'lucide-react';
interface MeetingMinimalHeaderProps {
roomName: string;
displayName?: string | null;
showLeaveButton?: boolean;
onLeave?: () => void;
showCreateButton?: boolean;
onCreateMeeting?: () => void;
isCreatingMeeting?: boolean;
}
export default function MeetingMinimalHeader({
roomName,
displayName,
showLeaveButton = true,
onLeave,
showCreateButton = false,
onCreateMeeting,
isCreatingMeeting = false,
}: MeetingMinimalHeaderProps) {
const navigate = useNavigate();
const handleLeaveMeeting = () => {
if (onLeave) {
onLeave();
} else {
navigate('/rooms');
}
};
const roomTitle = displayName
? displayName.endsWith("'s") || displayName.endsWith("s")
? `${displayName} Room`
: `${displayName}'s Room`
: `${roomName} Room`;
return (
<header className="flex justify-between items-center w-full py-4 px-6 bg-surface sticky top-0 z-10 border-b border-surface-high">
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center">
<Hexagon className="w-6 h-6 text-primary fill-primary/20" />
</Link>
<span className="font-serif text-lg font-semibold text-on-surface">
{roomTitle}
</span>
</div>
<div className="flex items-center gap-3">
{showCreateButton && onCreateMeeting && (
<Button
variant="primary"
onClick={onCreateMeeting}
disabled={isCreatingMeeting}
>
{isCreatingMeeting ? 'Creating...' : 'Create Meeting'}
</Button>
)}
{showLeaveButton && (
<Button
variant="secondary"
onClick={handleLeaveMeeting}
disabled={isCreatingMeeting}
className="border-[1.5px] border-[#C8C8BE] text-on-surface-variant hover:bg-surface-high"
>
Leave Room
</Button>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,363 @@
import React from 'react';
import { partition } from 'remeda';
import { useNavigate } from 'react-router-dom';
import type { components } from '../../lib/reflector-api';
import {
useRoomActiveMeetings,
useRoomJoinMeeting,
useMeetingDeactivate,
useRoomGetByName,
} from '../../lib/apiHooks';
import MeetingMinimalHeader from './MeetingMinimalHeader';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { ConfirmModal } from '../ui/ConfirmModal';
import { Users, Clock, Calendar, X as XIcon, Loader2 } from 'lucide-react';
import { formatDateTime, formatStartedAgo } from '../../lib/timeUtils';
type Meeting = components['schemas']['Meeting'];
type MeetingId = string;
interface MeetingSelectionProps {
roomName: string;
isOwner: boolean;
isSharedRoom: boolean;
authLoading: boolean;
onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void;
isCreatingMeeting?: boolean;
}
export default function MeetingSelection({
roomName,
isOwner,
isSharedRoom,
onMeetingSelect,
onCreateUnscheduled,
isCreatingMeeting = false,
}: MeetingSelectionProps) {
const navigate = useNavigate();
const roomQuery = useRoomGetByName(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const deactivateMeetingMutation = useMeetingDeactivate();
const room = roomQuery.data;
const allMeetings = activeMeetingsQuery.data || [];
const now = new Date();
const [currentMeetings, nonCurrentMeetings] = partition(
allMeetings,
(meeting) => {
const startTime = new Date(meeting.start_date);
const endTime = new Date(meeting.end_date);
return now >= startTime && now <= endTime;
}
);
const upcomingMeetings = nonCurrentMeetings.filter((meeting) => {
const startTime = new Date(meeting.start_date);
return now < startTime;
});
const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading;
const error = roomQuery.error || activeMeetingsQuery.error;
const handleJoinUpcoming = async (meeting: Meeting) => {
try {
const joinedMeeting = await joinMeetingMutation.mutateAsync({
params: {
path: {
room_name: roomName,
meeting_id: meeting.id,
},
},
});
onMeetingSelect(joinedMeeting);
} catch (err) {
console.error('Failed to join upcoming meeting:', err);
}
};
const handleJoinDirect = (meeting: Meeting) => {
onMeetingSelect(meeting);
};
const [meetingIdToEnd, setMeetingIdToEnd] = React.useState<MeetingId | null>(null);
const handleEndMeeting = async (meetingId: MeetingId) => {
try {
await deactivateMeetingMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
});
setMeetingIdToEnd(null);
} catch (err) {
console.error('Failed to end meeting:', err);
}
};
const handleLeaveMeeting = () => {
navigate('/rooms');
};
if (loading) {
return (
<div className="p-8 text-center flex flex-col justify-center items-center h-screen bg-surface">
<Loader2 className="w-10 h-10 text-primary animate-spin mb-4" />
<p className="font-serif italic text-muted">Retrieving meetings...</p>
</div>
);
}
if (error) {
return (
<div className="p-4 rounded-md bg-red-50 border-l-4 border-red-400 max-w-lg mx-auto mt-20">
<p className="font-semibold text-red-800">Error</p>
<p className="text-red-700">Failed to load meetings</p>
</div>
);
}
return (
<div className="flex flex-col min-h-screen relative bg-surface selection:bg-primary-fixed">
{isCreatingMeeting && (
<div className="fixed inset-0 bg-[#1b1c14]/45 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-white p-8 rounded-xl shadow-xl flex flex-col gap-4 items-center">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
<p className="text-lg font-medium text-on-surface">Creating meeting...</p>
</div>
</div>
)}
<MeetingMinimalHeader
roomName={roomName}
displayName={room?.name}
showLeaveButton={true}
onLeave={handleLeaveMeeting}
showCreateButton={isOwner || isSharedRoom}
onCreateMeeting={onCreateUnscheduled}
isCreatingMeeting={isCreatingMeeting}
/>
<div className="flex flex-col w-full max-w-4xl mx-auto px-4 py-8 md:py-12 flex-1 gap-6 md:gap-8">
{/* Current Ongoing Meetings */}
{currentMeetings.length > 0 ? (
<div className="flex flex-col gap-6 mb-8">
{currentMeetings.map((meeting) => (
<Card key={meeting.id} className="w-full bg-surface-low p-6 md:p-8 rounded-xl">
<div className="flex flex-col md:flex-row justify-between items-stretch md:items-start gap-6">
<div className="flex flex-col items-start gap-4 flex-1">
<div className="flex items-center gap-2">
<Calendar className="w-6 h-6 text-primary" />
<h2 className="text-xl md:text-2xl font-bold font-serif text-on-surface">
{(meeting.calendar_metadata as any)?.title || 'Live Meeting'}
</h2>
</div>
{isOwner && (meeting.calendar_metadata as any)?.description && (
<p className="text-md md:text-lg text-on-surface-variant font-sans">
{(meeting.calendar_metadata as any).description}
</p>
)}
<div className="flex gap-4 md:gap-8 text-sm md:text-base text-muted flex-wrap font-sans">
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
<span className="font-medium">
{meeting.num_clients || 0} participant{meeting.num_clients !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
<span>Started {formatStartedAgo(new Date(meeting.start_date))}</span>
</div>
</div>
{isOwner && (meeting.calendar_metadata as any)?.attendees && (
<div className="flex gap-2 flex-wrap mt-2">
{(meeting.calendar_metadata as any).attendees.slice(0, 4).map((att: any, idx: number) => (
<span key={idx} className="bg-primary/10 text-primary text-xs px-2.5 py-1 rounded-full font-semibold">
{att.name || att.email}
</span>
))}
{(meeting.calendar_metadata as any).attendees.length > 4 && (
<span className="bg-surface-high text-muted text-xs px-2.5 py-1 rounded-full font-semibold">
+ {(meeting.calendar_metadata as any).attendees.length - 4} more
</span>
)}
</div>
)}
</div>
<div className="flex flex-col gap-3 w-full md:w-auto mt-4 md:mt-0">
<Button
variant="primary"
className="py-3 px-6 text-base"
onClick={() => handleJoinDirect(meeting)}
>
<Users className="w-5 h-5 mr-2" />
Join Now
</Button>
{isOwner && (
<Button
variant="secondary"
className="py-2.5 border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
onClick={() => setMeetingIdToEnd(meeting.id as string)}
disabled={deactivateMeetingMutation.isPending}
>
{deactivateMeetingMutation.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <XIcon className="w-4 h-4 mr-2" />}
End Meeting
</Button>
)}
</div>
</div>
</Card>
))}
</div>
) : upcomingMeetings.length > 0 ? (
/* Upcoming Meetings - Big Display */
<div className="flex flex-col gap-6 mb-8">
<h3 className="text-xl font-bold font-serif text-on-surface">
Upcoming Meeting{upcomingMeetings.length > 1 ? 's' : ''}
</h3>
{upcomingMeetings.map((meeting) => {
const now = new Date();
const startTime = new Date(meeting.start_date);
const minutesUntilStart = Math.floor((startTime.getTime() - now.getTime()) / (1000 * 60));
return (
<Card key={meeting.id} className="w-full bg-[#E5ECE5]/40 border-primary/20 p-6 md:p-8 rounded-xl">
<div className="flex flex-col md:flex-row justify-between items-stretch md:items-start gap-6">
<div className="flex flex-col items-start gap-4 flex-1">
<div className="flex items-center gap-2">
<Calendar className="w-6 h-6 text-primary" />
<h2 className="text-xl md:text-2xl font-bold font-serif text-primary">
{(meeting.calendar_metadata as any)?.title || 'Upcoming Meeting'}
</h2>
</div>
{isOwner && (meeting.calendar_metadata as any)?.description && (
<p className="text-md md:text-lg text-on-surface-variant">
{(meeting.calendar_metadata as any).description}
</p>
)}
<div className="flex gap-4 md:gap-6 text-sm md:text-base text-muted flex-wrap items-center">
<span className="bg-primary/10 text-primary font-semibold text-sm px-3 py-1 rounded-full">
Starts in {minutesUntilStart} minute{minutesUntilStart !== 1 ? 's' : ''}
</span>
<span className="text-muted font-sans">
{formatDateTime(new Date(meeting.start_date))}
</span>
</div>
{isOwner && (meeting.calendar_metadata as any)?.attendees && (
<div className="flex gap-2 flex-wrap">
{(meeting.calendar_metadata as any).attendees.slice(0, 4).map((att: any, idx: number) => (
<span key={idx} className="bg-white/50 border border-primary/10 text-primary text-xs px-2.5 py-1 rounded-full font-semibold">
{att.name || att.email}
</span>
))}
{(meeting.calendar_metadata as any).attendees.length > 4 && (
<span className="bg-surface-high text-muted text-xs px-2.5 py-1 rounded-full font-semibold">
+ {(meeting.calendar_metadata as any).attendees.length - 4} more
</span>
)}
</div>
)}
</div>
<div className="flex flex-col gap-3 w-full md:w-auto mt-4 md:mt-0">
<Button
variant="primary"
onClick={() => handleJoinUpcoming(meeting)}
className="bg-primary hover:bg-primary-hover shadow-sm"
>
<Clock className="w-4 h-4 mr-2" />
Join Early
</Button>
{isOwner && (
<Button
variant="secondary"
onClick={() => setMeetingIdToEnd(meeting.id as string)}
disabled={deactivateMeetingMutation.isPending}
className="border-surface-highest text-muted hover:text-red-600 hover:border-red-200"
>
Cancel Meeting
</Button>
)}
</div>
</div>
</Card>
);
})}
</div>
) : null}
{/* Small Upcoming Display if Ongoing EXISTS */}
{currentMeetings.length > 0 && upcomingMeetings.length > 0 && (
<div className="flex flex-col gap-4 mb-6 pt-4 border-t border-surface-high">
<h3 className="text-lg font-semibold font-serif text-on-surface-variant">Starting Soon</h3>
<div className="flex gap-4 flex-wrap flex-col sm:flex-row">
{upcomingMeetings.map((meeting) => {
const now = new Date();
const startTime = new Date(meeting.start_date);
const minutesUntilStart = Math.floor((startTime.getTime() - now.getTime()) / (1000 * 60));
return (
<div key={meeting.id} className="bg-surface border border-primary/20 rounded-lg p-5 min-w-[280px] hover:border-primary/40 transition-colors">
<div className="flex flex-col items-start gap-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<span className="font-semibold text-md text-on-surface">
{(meeting.calendar_metadata as any)?.title || 'Upcoming'}
</span>
</div>
<span className="bg-primary/10 text-primary font-semibold text-xs px-2 py-1 rounded-md">
in {minutesUntilStart} minute{minutesUntilStart !== 1 ? 's' : ''}
</span>
<span className="text-xs text-muted font-sans">
Starts: {formatDateTime(new Date(meeting.start_date))}
</span>
<Button variant="primary" className="w-full mt-1 text-sm py-1.5" onClick={() => handleJoinUpcoming(meeting)}>
Join Early
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* No Meetings Fallback */}
{currentMeetings.length === 0 && upcomingMeetings.length === 0 && (
<div className="flex flex-col w-full flex-1 justify-center items-center text-center pb-20 mt-10">
<div className="w-20 h-20 rounded-full bg-surface-high flex items-center justify-center mb-6">
<Calendar className="w-10 h-10 text-on-surface-variant opacity-40" />
</div>
<h2 className="text-2xl font-semibold font-serif text-on-surface mb-2">No meetings active</h2>
<p className="text-muted max-w-sm font-sans text-[0.9375rem] leading-relaxed">
There are no ongoing or upcoming calendar meetings parsed for this room currently.
</p>
</div>
)}
</div>
<ConfirmModal
isOpen={meetingIdToEnd !== null}
onClose={() => setMeetingIdToEnd(null)}
onConfirm={() => meetingIdToEnd && handleEndMeeting(meetingIdToEnd)}
title="End Meeting"
description="Are you sure you want to end this calendar event's recording context? This will deactivate the session for all participants and cannot be undone."
confirmText="End Meeting"
isDestructive={true}
isLoading={deactivateMeetingMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import type { components } from '../../lib/reflector-api';
import { useAuth } from '../../lib/AuthProvider';
import { getWherebyUrl, useWhereby } from '../../lib/wherebyClient';
import { assertMeetingId } from '../../lib/types';
import { ConsentDialogButton as BaseConsentDialogButton, useConsentDialog } from '../../lib/consent';
type Meeting = components['schemas']['Meeting'];
type Room = components['schemas']['RoomDetails'];
type MeetingId = string;
interface WherebyRoomProps {
meeting: Meeting;
room: Room;
}
function WherebyConsentDialogButton({
onClick,
wherebyRef,
}: {
onClick: () => void;
wherebyRef: React.RefObject<HTMLElement | null>;
}) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const element = wherebyRef.current;
if (!element) return;
const handleWherebyReady = () => {
previousFocusRef.current = document.activeElement as HTMLElement;
};
element.addEventListener('ready', handleWherebyReady);
return () => {
element.removeEventListener('ready', handleWherebyReady);
if (previousFocusRef.current && document.activeElement === element) {
previousFocusRef.current.focus();
}
};
}, [wherebyRef]);
return (
<BaseConsentDialogButton onClick={onClick} />
);
}
export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const navigate = useNavigate();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === 'authenticated';
const wherebyRoomUrl = getWherebyUrl(meeting);
const meetingId = meeting.id;
const { showConsentButton, showConsentModal } = useConsentDialog({
meetingId: assertMeetingId(meetingId),
recordingType: meeting.recording_type,
skipConsent: room.skip_consent,
});
const showConsentModalRef = useRef(showConsentModal);
showConsentModalRef.current = showConsentModal;
const isLoading = status === 'loading';
const handleLeave = useCallback(() => {
navigate('/transcriptions');
}, [navigate]);
useEffect(() => {
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) return;
const currentRef = wherebyRef.current;
if (currentRef) {
currentRef.addEventListener('leave', handleLeave as EventListener);
}
return () => {
if (currentRef) {
currentRef.removeEventListener('leave', handleLeave as EventListener);
}
};
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
if (!wherebyRoomUrl || !wherebyLoaded) {
return null;
}
// Inject Web Component tag for whereby native support
return (
<>
<whereby-embed
ref={wherebyRef as any}
room={wherebyRoomUrl}
style={{ width: '100vw', height: '100vh', border: 'none' }}
/>
{showConsentButton && (
<WherebyConsentDialogButton
onClick={() => showConsentModalRef.current()}
wherebyRef={wherebyRef}
/>
)}
</>
);
}
// Add the web component declaration for React TypeScript integration
declare global {
namespace JSX {
interface IntrinsicElements {
'whereby-embed': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
room: string;
style?: React.CSSProperties;
ref?: React.Ref<any>;
},
HTMLElement
>;
}
}
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Bot, Sparkles } from 'lucide-react';
export function ProcessingView() {
return (
<div className="flex-1 min-h-[500px] w-full max-w-2xl mx-auto px-6 flex flex-col items-center justify-center">
<div className="relative mb-12">
<div className="absolute inset-0 w-32 h-32 bg-primary/20 rounded-full blur-2xl animate-pulse" />
<div className="relative bg-surface p-6 rounded-3xl border border-primary/20 shadow-xl shadow-primary/5 flex items-center justify-center">
<Bot className="w-12 h-12 text-primary animate-bounce" />
</div>
</div>
<div className="text-center space-y-4 max-w-md">
<h2 className="font-serif font-bold text-3xl text-on-surface">Curating your archive...</h2>
<p className="text-on-surface-variant text-[0.9375rem] leading-relaxed">
The Reflector extraction engine is analyzing the audio. This typically takes a few moments depending on the recording length.
</p>
</div>
<div className="mt-12 flex space-x-2">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" />
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { useAudioDevice } from '../../hooks/useAudioDevice';
import { useWebSockets } from '../../hooks/transcripts/useWebSockets';
import { useWebRTC } from '../../hooks/transcripts/useWebRTC';
import { Button } from '../ui/Button';
import { ConfirmModal } from '../ui/ConfirmModal';
import { Mic, Play, Square, StopCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface RecordViewProps {
transcriptId: string;
}
export function RecordView({ transcriptId }: RecordViewProps) {
const navigate = useNavigate();
const waveformRef = useRef<HTMLDivElement>(null);
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
const [recordPlugin, setRecordPlugin] = useState<any>(null);
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [currentStream, setCurrentStream] = useState<MediaStream | null>(null);
const [isConfirmEndOpen, setIsConfirmEndOpen] = useState(false);
const { permissionOk, requestPermission, audioDevices } = useAudioDevice();
const [selectedDevice, setSelectedDevice] = useState<string>('');
// Establish WebSockets for transcription data and exact API duration tracking
const wsData = useWebSockets(transcriptId);
const _rtcPeerConnection = useWebRTC(currentStream, isRecording ? transcriptId : null);
useEffect(() => {
if (audioDevices.length > 0) {
setSelectedDevice(audioDevices[0].value);
}
}, [audioDevices]);
// Handle server redirection upon stream termination & successful inference processing
useEffect(() => {
if (wsData.status?.value === "ended" || wsData.status?.value === "error") {
navigate(`/transcriptions/${transcriptId}`);
}
}, [wsData.status?.value, navigate, transcriptId]);
useEffect(() => {
if (!waveformRef.current) return;
const ws = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(160, 154, 142, 0.5)',
progressColor: '#DC5A28',
height: 100,
barWidth: 3,
barGap: 2,
barRadius: 3,
normalize: true,
cursorWidth: 0,
});
const rec = ws.registerPlugin(RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}));
setWavesurfer(ws);
setRecordPlugin(rec);
return () => {
rec.destroy();
ws.destroy();
};
}, []);
const startRecording = async () => {
if (!permissionOk) {
requestPermission();
return;
}
if (recordPlugin) {
try {
// Native browser constraints specifically isolated for the elected input device
const freshStream = await navigator.mediaDevices.getUserMedia({
audio: selectedDevice ? { deviceId: { exact: selectedDevice } } : true
});
setCurrentStream(freshStream);
// Push duplicate explicit stream into Wavesurfer record plugin
await recordPlugin.startRecording(freshStream);
setIsRecording(true);
setIsPaused(false);
} catch (err) {
console.error("Failed to inject stream into local constraints", err);
}
}
};
const pauseRecording = () => {
if (recordPlugin && isRecording) {
if (isPaused) {
recordPlugin.resumeRecording();
setIsPaused(false);
} else {
recordPlugin.pauseRecording();
setIsPaused(true);
}
}
};
const stopRecording = () => {
setIsConfirmEndOpen(false);
if (recordPlugin && isRecording) {
recordPlugin.stopRecording();
setIsRecording(false);
setIsPaused(false);
if (currentStream) {
currentStream.getTracks().forEach(t => t.stop());
setCurrentStream(null);
}
}
};
const formatDuration = (seconds: number | null) => {
if (seconds == null) return "00:00:00";
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="flex-1 flex flex-col w-full max-w-4xl mx-auto px-6 py-12 h-screen max-h-screen">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="font-serif font-bold text-3xl text-on-surface">Live Recording</h2>
<p className="text-on-surface-variant mt-1 text-sm">
Capturing audio for transcript ID: {transcriptId.substring(0, 8)}...
</p>
</div>
<div className="w-64">
<select
className="w-full bg-surface-low border border-outline-variant/30 text-on-surface text-sm rounded-lg px-3 py-2 outline-none focus:border-primary transition-colors cursor-pointer"
value={selectedDevice}
onChange={(e) => setSelectedDevice(e.target.value)}
disabled={isRecording}
>
{audioDevices.map(device => (
<option key={device.value} value={device.value}>{device.label}</option>
))}
</select>
</div>
</div>
<div className="bg-surface-low p-8 rounded-2xl border border-outline-variant/20 shadow-sm flex flex-col mb-8 relative">
{/* Dynamic websocket ping duration display mapped off python API output */}
{isRecording && (
<div className="absolute top-4 left-6 flex items-center gap-3 bg-red-500/10 text-red-600 px-3 py-1.5 rounded-full z-20">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="font-mono text-sm font-bold tracking-wider">{formatDuration(wsData.duration)}</span>
</div>
)}
{/* Visualization Area */}
<div className="bg-surface-mid rounded-xl overflow-hidden p-6 mb-8 border border-outline-variant/30 relative h-[160px] flex items-center justify-center">
{!permissionOk && !isRecording && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<Mic className="w-8 h-8 text-white/50 mb-2" />
<Button variant="secondary" onClick={requestPermission} className="bg-white/10 border-white/20 text-white hover:bg-white/20">
Allow Microphone
</Button>
</div>
)}
<div ref={waveformRef} className="w-full relative z-0" />
</div>
{/* Play/Pause/Stop Global Controls */}
<div className="flex items-center justify-center gap-6">
{!isRecording ? (
<Button
onClick={startRecording}
disabled={!permissionOk}
className="bg-red-500 hover:bg-red-600 text-white rounded-full w-16 h-16 flex items-center justify-center shadow-[0_0_15px_rgba(239,68,68,0.4)] hover:shadow-[0_0_20px_rgba(239,68,68,0.6)] transition-all"
>
<div className="w-5 h-5 bg-white rounded-full" />
</Button>
) : (
<>
{/* Note: WebRTC streams are natively active until tracks are stripped.
Therefore 'pause' locally suspends WaveSurfer drawing logic,
but active WebRTC pipe persists until standard "stop" terminates it.
*/}
<Button
onClick={pauseRecording}
className="bg-surface-high hover:bg-outline-variant/20 text-on-surface rounded-full w-14 h-14 flex items-center justify-center transition-colors"
title={isPaused ? "Resume visualization" : "Pause visualization"}
>
{isPaused ? <Play className="w-5 h-5 fill-current" /> : <Square className="w-5 h-5 fill-current" />}
</Button>
<Button
onClick={() => setIsConfirmEndOpen(true)}
className="bg-red-500 hover:bg-red-600 text-white rounded-full w-16 h-16 flex items-center justify-center shadow-lg shadow-red-500/20"
title="Conclude Recording & Proceed"
>
<StopCircle className="w-8 h-8" />
</Button>
</>
)}
</div>
</div>
{/* Live Transcript Pane tracking wsData real-time ingestion */}
<div className="flex-1 bg-surface-low rounded-2xl border border-outline-variant/20 p-6 flex flex-col overflow-hidden">
<h3 className="text-xs font-bold uppercase tracking-widest text-muted mb-4 flex items-center gap-2">
{isRecording && <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />}
Live Transcript Pipeline
</h3>
<div className="flex-1 overflow-y-auto w-full max-w-full">
{wsData.transcriptTextLive || wsData.accumulatedText ? (
<div className="text-on-surface font-sans text-lg leading-relaxed flex flex-col gap-2">
<span className="opacity-60">{wsData.accumulatedText.replace(wsData.transcriptTextLive, '').trim()}</span>
<span className="font-semibold">{wsData.transcriptTextLive}</span>
</div>
) : (
<div className="h-full flex items-center justify-center text-on-surface-variant font-mono text-sm opacity-50">
{isRecording ? "Transmitting audio and calculating text..." : "Connect WebRTC to preview transcript pipeline."}
</div>
)}
</div>
</div>
<ConfirmModal
isOpen={isConfirmEndOpen}
onClose={() => setIsConfirmEndOpen(false)}
onConfirm={stopRecording}
title="End Live Recording"
description="Are you sure you want to stop recording? This will finalize the transcript and begin generating summaries. You will not be able to resume this session."
confirmText="Yes, End Recording"
isDestructive={false}
/>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import React, { useState, useRef } from 'react';
import { useTranscriptUploadAudio } from '../../lib/apiHooks';
import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/Button';
import { UploadCloud, CheckCircle2 } from 'lucide-react';
interface UploadViewProps {
transcriptId: string;
}
export function UploadView({ transcriptId }: UploadViewProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadMutation = useTranscriptUploadAudio();
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
const maxChunkSize = 50 * 1024 * 1024; // 50 MB
const totalChunks = Math.ceil(file.size / maxChunkSize);
let chunkNumber = 0;
let start = 0;
let uploadedSize = 0;
const uploadNextChunk = async () => {
if (chunkNumber === totalChunks) {
setProgress(100);
return;
}
const chunkSize = Math.min(maxChunkSize, file.size - start);
const end = start + chunkSize;
const chunk = file.slice(start, end);
try {
const formData = new FormData();
formData.append("chunk", chunk, file.name);
await uploadMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId as any,
},
query: {
chunk_number: chunkNumber,
total_chunks: totalChunks,
},
},
body: formData as any,
});
uploadedSize += chunkSize;
const currentProgress = Math.floor((uploadedSize / file.size) * 100);
setProgress(currentProgress);
chunkNumber++;
start = end;
await uploadNextChunk();
} catch (err: any) {
console.error(err);
setError("Failed to upload file. Please try again.");
setProgress(0);
}
};
uploadNextChunk();
};
return (
<div className="flex-1 flex flex-col items-center justify-center min-h-[500px] w-full max-w-2xl mx-auto px-6">
<div className="w-full text-center space-y-4 mb-8">
<h2 className="font-serif font-bold text-3xl text-on-surface">Upload Meeting Audio</h2>
<p className="text-on-surface-variant text-[0.9375rem]">
Select an audio or video file to generate an editorial transcript.
</p>
</div>
<div className="w-full bg-surface-low border-2 border-dashed border-outline-variant/30 rounded-xl p-12 flex flex-col items-center justify-center text-center space-y-6 transition-colors hover:border-primary/50 hover:bg-surface-low/80">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-2">
{progress === 100 ? <CheckCircle2 className="w-8 h-8 text-green-600" /> : <UploadCloud className="w-8 h-8" />}
</div>
{progress > 0 && progress < 100 ? (
<div className="w-full max-w-xs space-y-3">
<div className="flex justify-between text-sm font-medium text-on-surface-variant">
<span>Uploading...</span>
<span>{progress}%</span>
</div>
<div className="h-2 w-full bg-surface-high rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : progress === 100 ? (
<div className="text-green-700 font-medium text-sm">Upload complete! Processing will begin momentarily.</div>
) : (
<>
<div>
<p className="font-semibold text-on-surface mb-1">Click to select a file</p>
<p className="text-xs text-muted">Supported formats: .mp3, .m4a, .wav, .mp4, .mov, .webm</p>
</div>
<Button
onClick={triggerFileUpload}
variant="primary"
className="px-8"
disabled={progress > 0}
>
Select File
</Button>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileUpload}
accept="audio/*,video/mp4,video/webm,video/quicktime"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
import { TopicWordsEditor } from "./TopicWordsEditor";
import { ParticipantSidebar } from "./ParticipantSidebar";
import { SelectedText } from "./types";
import { useTranscriptParticipants } from "../../../lib/apiHooks";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
type CorrectionEditorProps = {
transcriptId: string;
topics: any[]; // List of topic objects [{id, title, ...}]
onClose: () => void;
};
export function CorrectionEditor({ transcriptId, topics, onClose }: CorrectionEditorProps) {
const [currentTopicId, setCurrentTopicId] = useState<string | null>(null);
const stateSelectedText = useState<SelectedText>(undefined);
const { data: participantsData, isLoading: isParticipantsLoading, refetch: refetchParticipants } = useTranscriptParticipants(transcriptId as any);
// Initialize with first topic or restored session topic
useEffect(() => {
if (topics && topics.length > 0 && !currentTopicId) {
const sessionTopic = window.localStorage.getItem(`${transcriptId}_correct_topic`);
if (sessionTopic && topics.find((t: any) => t.id === sessionTopic)) {
setCurrentTopicId(sessionTopic);
} else {
setCurrentTopicId(topics[0].id);
}
}
}, [topics, currentTopicId, transcriptId]);
// Persist current topic to local storage tracking
useEffect(() => {
if (currentTopicId) {
window.localStorage.setItem(`${transcriptId}_correct_topic`, currentTopicId);
}
}, [currentTopicId, transcriptId]);
const currentIndex = topics.findIndex((t: any) => t.id === currentTopicId);
const currentTopic = topics[currentIndex];
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < topics.length - 1;
const onPrev = () => { if (canGoPrev) setCurrentTopicId(topics[currentIndex - 1].id); };
const onNext = () => { if (canGoNext) setCurrentTopicId(topics[currentIndex + 1].id); };
useEffect(() => {
const keyHandler = (e: KeyboardEvent) => {
// Don't intercept if they are typing in an input!
if (document.activeElement?.tagName === 'INPUT') return;
if (e.key === "ArrowLeft") onPrev();
else if (e.key === "ArrowRight") onNext();
};
document.addEventListener("keyup", keyHandler);
return () => document.removeEventListener("keyup", keyHandler);
}, [currentIndex, topics]);
return (
<div className="flex flex-col h-[calc(100vh-140px)] w-full relative">
<div className="flex items-center justify-between p-4 bg-surface-low border-b border-outline-variant/10 rounded-t-xl shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 bg-surface-high p-1 rounded-md border border-outline-variant/20">
<button
onClick={onPrev}
disabled={!canGoPrev}
className="p-1.5 rounded hover:bg-surface disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
title="Previous Topic (Left Arrow)"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canGoNext}
className="p-1.5 rounded hover:bg-surface disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
title="Next Topic (Right Arrow)"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-muted bg-surface-high px-2 py-1 rounded">
{currentIndex >= 0 ? currentIndex + 1 : 0} / {topics.length}
</span>
<h3 className="font-serif text-lg font-bold truncate max-w-[300px]">
{currentTopic?.title || "Loading..."}
</h3>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-muted hover:text-red-500 hover:bg-red-50 transition-colors rounded-full"
title="Exit Correction Mode"
>
<XIcon className="w-5 h-5" />
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Editor Central Area */}
<div className="flex-1 p-6 overflow-y-auto bg-surface relative min-h-0">
<div className="max-w-3xl mx-auto h-full pr-4">
<h4 className="text-xs font-bold tracking-widest text-primary uppercase mb-6 flex items-center">
<span className="w-2 h-2 rounded-full bg-primary mr-2 animate-pulse"></span>
Correction Mode
</h4>
{currentTopicId ? (
<TopicWordsEditor
transcriptId={transcriptId}
topicId={currentTopicId}
stateSelectedText={stateSelectedText}
participants={participantsData || []}
/>
) : (
<div className="text-muted text-sm text-center py-20">No topic selected</div>
)}
</div>
</div>
{/* Participant Assignment Sidebar */}
<div className="w-80 shrink-0 border-l border-outline-variant/10 bg-surface-low p-4 overflow-y-auto hidden md:block min-h-0">
{currentTopicId && (
<ParticipantSidebar
transcriptId={transcriptId}
topicId={currentTopicId}
participants={participantsData || []}
isParticipantsLoading={isParticipantsLoading}
refetchParticipants={refetchParticipants}
stateSelectedText={stateSelectedText}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,331 @@
import { ChangeEvent, useEffect, useRef, useState } from "react";
import {
useTranscriptSpeakerAssign,
useTranscriptSpeakerMerge,
useTranscriptParticipantUpdate,
useTranscriptParticipantCreate,
useTranscriptParticipantDelete,
} from "../../../lib/apiHooks";
import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./types";
import { Button } from "../../ui/Button";
import { CornerDownRight, Loader2 } from "lucide-react";
type ParticipantSidebarProps = {
transcriptId: string;
topicId: string;
participants: any[];
isParticipantsLoading: boolean;
refetchParticipants: () => void;
stateSelectedText: any;
};
export function ParticipantSidebar({
transcriptId,
participants,
isParticipantsLoading,
refetchParticipants,
stateSelectedText,
}: ParticipantSidebarProps) {
const speakerAssignMutation = useTranscriptSpeakerAssign();
const speakerMergeMutation = useTranscriptSpeakerMerge();
const participantUpdateMutation = useTranscriptParticipantUpdate();
const participantCreateMutation = useTranscriptParticipantCreate();
const participantDeleteMutation = useTranscriptParticipantDelete();
const loading =
speakerAssignMutation.isPending ||
speakerMergeMutation.isPending ||
participantUpdateMutation.isPending ||
participantCreateMutation.isPending ||
participantDeleteMutation.isPending;
const [participantInput, setParticipantInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [selectedText, setSelectedText] = stateSelectedText;
const [selectedParticipant, setSelectedParticipant] = useState<any>();
const [action, setAction] = useState<"Create" | "Create to rename" | "Create and assign" | "Rename" | null>(null);
const [oneMatch, setOneMatch] = useState<any>();
useEffect(() => {
if (participants && participants.length > 0) {
if (selectedTextIsSpeaker(selectedText)) {
inputRef.current?.focus();
const participant = participants.find((p) => p.speaker === selectedText);
if (participant) {
setParticipantInput(participant.name);
setOneMatch(undefined);
setSelectedParticipant(participant);
setAction("Rename");
} else {
setSelectedParticipant(undefined);
setParticipantInput("");
setOneMatch(undefined);
setAction("Create to rename");
}
}
if (selectedTextIsTimeSlice(selectedText)) {
inputRef.current?.focus();
setParticipantInput("");
setOneMatch(undefined);
setAction("Create and assign");
setSelectedParticipant(undefined);
}
if (typeof selectedText === "undefined") {
inputRef.current?.blur();
setSelectedParticipant(undefined);
setAction(null);
}
}
}, [selectedText, participants]);
const onSuccess = () => {
refetchParticipants();
setAction(null);
setSelectedText(undefined);
setSelectedParticipant(undefined);
setParticipantInput("");
setOneMatch(undefined);
inputRef?.current?.blur();
};
const assignTo = (participant: any) => async (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (loading || isParticipantsLoading) return;
if (!selectedTextIsTimeSlice(selectedText)) return;
try {
await speakerAssignMutation.mutateAsync({
params: { path: { transcript_id: transcriptId as any } },
body: {
participant: participant.id,
timestamp_from: selectedText.start,
timestamp_to: selectedText.end,
},
});
onSuccess();
} catch (error) {
console.error(error);
}
};
const mergeSpeaker = (speakerFrom: number, participantTo: any) => async () => {
if (loading || isParticipantsLoading) return;
if (participantTo.speaker) {
try {
await speakerMergeMutation.mutateAsync({
params: { path: { transcript_id: transcriptId as any } },
body: {
speaker_from: speakerFrom,
speaker_to: participantTo.speaker,
},
});
onSuccess();
} catch (error) {
console.error(error);
}
} else {
try {
await participantUpdateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId as any,
participant_id: participantTo.id,
},
},
body: { speaker: speakerFrom },
});
onSuccess();
} catch (error) {
console.error(error);
}
}
};
const doAction = async (e?: any) => {
e?.preventDefault();
e?.stopPropagation();
if (loading || isParticipantsLoading || !participants) return;
if (action === "Rename" && selectedTextIsSpeaker(selectedText)) {
const participant = participants.find((p) => p.speaker === selectedText);
if (participant && participant.name !== participantInput) {
try {
await participantUpdateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId as any,
participant_id: participant.id,
},
},
body: { name: participantInput },
});
refetchParticipants();
setAction(null);
} catch (e) {
console.error(e);
}
}
} else if (action === "Create to rename" && selectedTextIsSpeaker(selectedText)) {
try {
await participantCreateMutation.mutateAsync({
params: { path: { transcript_id: transcriptId as any } },
body: { name: participantInput, speaker: selectedText },
});
refetchParticipants();
setParticipantInput("");
setOneMatch(undefined);
} catch (e) {
console.error(e);
}
} else if (action === "Create and assign" && selectedTextIsTimeSlice(selectedText)) {
try {
const participant = await participantCreateMutation.mutateAsync({
params: { path: { transcript_id: transcriptId as any } },
body: { name: participantInput },
});
assignTo(participant)();
} catch (error) {
console.error(error);
}
} else if (action === "Create") {
try {
await participantCreateMutation.mutateAsync({
params: { path: { transcript_id: transcriptId as any } },
body: { name: participantInput },
});
refetchParticipants();
setParticipantInput("");
inputRef.current?.focus();
} catch (e) {
console.error(e);
}
}
};
const deleteParticipant = (participantId: string) => async (e: any) => {
e.stopPropagation();
if (loading || isParticipantsLoading) return;
try {
await participantDeleteMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId as any,
participant_id: participantId,
},
},
});
refetchParticipants();
} catch (e) {
console.error(e);
}
};
const selectParticipant = (participant: any) => (e: any) => {
e.stopPropagation();
setSelectedParticipant(participant);
setSelectedText(participant.speaker);
setAction("Rename");
setParticipantInput(participant.name);
oneMatch && setOneMatch(undefined);
};
const clearSelection = () => {
setSelectedParticipant(undefined);
setSelectedText(undefined);
setAction(null);
setParticipantInput("");
oneMatch && setOneMatch(undefined);
};
const changeParticipantInput = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replaceAll(/,|\.| /g, "");
setParticipantInput(value);
if (value.length > 0 && participants && (action === "Create and assign" || action === "Create to rename")) {
const matches = participants.filter((p) => p.name.toLowerCase().startsWith(value.toLowerCase()));
if (matches.length === 1) {
setOneMatch(matches[0]);
} else {
setOneMatch(undefined);
}
}
if (value.length > 0 && !action) {
setAction("Create");
}
};
const anyLoading = loading || isParticipantsLoading;
return (
<div className="h-full flex flex-col w-full bg-surface-low border border-outline-variant/20 rounded-xl overflow-hidden shadow-sm" onClick={clearSelection}>
<div className="p-4 border-b border-outline-variant/10 bg-surface/50" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
type="text"
onChange={changeParticipantInput}
value={participantInput}
placeholder="Participant Name"
className="w-full bg-surface border border-outline-variant/20 rounded-lg px-3 py-2 text-sm text-on-surface placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 mb-3"
/>
<Button
onClick={doAction}
disabled={!action || anyLoading}
className="w-full py-2 bg-primary text-white flex items-center justify-center font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary/90 transition-colors"
>
{!anyLoading ? (
<>
<CornerDownRight className="w-3 h-3 mr-2 opacity-70" />
{action || "Create"}
</>
) : (
<Loader2 className="w-4 h-4 animate-spin" />
)}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1" onClick={(e) => e.stopPropagation()}>
{participants?.map((participant) => (
<div
key={participant.id}
onClick={selectParticipant(participant)}
className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors group ${
(participantInput.length > 0 && selectedText && participant.name.toLowerCase().startsWith(participantInput.toLowerCase())
? "bg-primary/10 border-primary/20"
: "border-transparent") +
(participant.id === selectedParticipant?.id ? " bg-primary/10 border border-primary text-primary" : " hover:bg-surface border")
}`}
>
<span className="text-sm font-medium">{participant.name}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{action === "Create to rename" && !selectedParticipant && !loading && (
<button
onClick={mergeSpeaker(selectedText, participant)}
className="text-[10px] uppercase font-bold tracking-wider px-2 py-1 bg-surface-high rounded hover:bg-primary hover:text-white transition-colors"
>
Merge
</button>
)}
{selectedTextIsTimeSlice(selectedText) && !loading && (
<button
onClick={assignTo(participant)}
className="text-[10px] uppercase font-bold tracking-wider px-2 py-1 bg-surface-high rounded hover:bg-primary hover:text-white transition-colors"
>
Assign
</button>
)}
<button
onClick={deleteParticipant(participant.id)}
className="text-[10px] uppercase font-bold tracking-wider px-2 py-1 bg-red-500/10 text-red-600 rounded hover:bg-red-500 hover:text-white transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { Dispatch, SetStateAction, useEffect } from "react";
import { TimeSlice, selectedTextIsTimeSlice } from "./types";
import { useTranscriptTopicsWithWordsPerSpeaker } from "../../../lib/apiHooks";
import { Loader2 } from "lucide-react";
type TopicWordsEditorProps = {
transcriptId: string;
topicId: string;
stateSelectedText: [
number | TimeSlice | undefined,
Dispatch<SetStateAction<number | TimeSlice | undefined>>,
];
participants: any[]; // List of resolved participants
};
export function TopicWordsEditor({
transcriptId,
topicId,
stateSelectedText,
participants,
}: TopicWordsEditorProps) {
const [selectedText, setSelectedText] = stateSelectedText;
const { data: topicWithWords, isLoading } = useTranscriptTopicsWithWordsPerSpeaker(
transcriptId as any,
topicId,
);
useEffect(() => {
if (isLoading && selectedTextIsTimeSlice(selectedText)) {
setSelectedText(undefined);
}
}, [isLoading]);
const getStartTimeFromFirstNode = (node: any, offset: number, reverse: boolean) => {
if (node.parentElement?.dataset["start"]) {
if (node.textContent?.length === offset) {
const nextWordStartTime = node.parentElement.nextElementSibling?.dataset["start"];
if (nextWordStartTime) return nextWordStartTime;
const nextParaFirstWordStartTime = node.parentElement.parentElement.nextElementSibling?.childNodes[1]?.dataset["start"];
if (nextParaFirstWordStartTime) return nextParaFirstWordStartTime;
return reverse ? 0 : 9999999999999;
} else {
return node.parentElement.dataset["start"];
}
} else {
return node.parentElement.nextElementSibling?.dataset["start"];
}
};
const onMouseUp = () => {
const selection = window.getSelection();
if (
selection &&
selection.anchorNode &&
selection.focusNode &&
selection.anchorNode === selection.focusNode &&
selection.anchorOffset === selection.focusOffset
) {
setSelectedText(undefined);
selection.empty();
return;
}
if (
selection &&
selection.anchorNode &&
selection.focusNode &&
(selection.anchorNode !== selection.focusNode ||
selection.anchorOffset !== selection.focusOffset)
) {
const anchorNode = selection.anchorNode;
const anchorIsWord = !!selection.anchorNode.parentElement?.dataset["start"];
const focusNode = selection.focusNode;
const focusIsWord = !!selection.focusNode.parentElement?.dataset["end"];
// If selected a speaker:
if (!anchorIsWord && !focusIsWord && anchorNode.parentElement === focusNode.parentElement) {
const speaker = focusNode.parentElement?.dataset["speaker"];
setSelectedText(speaker ? parseInt(speaker, 10) : undefined);
return;
}
const anchorStart = getStartTimeFromFirstNode(anchorNode, selection.anchorOffset, false);
const focusEnd =
selection.focusOffset !== 0
? selection.focusNode.parentElement?.dataset["end"] ||
(selection.focusNode.parentElement?.parentElement?.previousElementSibling?.lastElementChild as any)?.dataset["end"]
: (selection.focusNode.parentElement?.previousElementSibling as any)?.dataset["end"] || 0;
const reverse = parseFloat(anchorStart) >= parseFloat(focusEnd);
if (!reverse) {
if (anchorStart && focusEnd) {
setSelectedText({
start: parseFloat(anchorStart),
end: parseFloat(focusEnd),
});
}
} else {
const anchorEnd =
anchorNode.parentElement?.dataset["end"] ||
(selection.anchorNode.parentElement?.parentElement?.previousElementSibling?.lastElementChild as any)?.dataset["end"];
const focusStart = getStartTimeFromFirstNode(focusNode, selection.focusOffset, true);
setSelectedText({
start: parseFloat(focusStart),
end: parseFloat(anchorEnd),
});
}
}
selection && selection.empty();
};
const getSpeakerName = (speakerNumber: number) => {
if (!participants) return `Speaker ${speakerNumber}`;
return (
participants.find((p: any) => p.speaker === speakerNumber)?.name ||
`Speaker ${speakerNumber}`
);
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary/50" />
</div>
);
}
if (topicWithWords && participants) {
return (
<div
onMouseUp={onMouseUp}
className="max-h-full w-full overflow-y-auto pr-4 text-[0.9375rem] leading-relaxed selection:bg-primary/20"
>
{topicWithWords.words_per_speaker?.map((speakerWithWords: any, index: number) => (
<p key={index} className="mb-4 last:mb-0">
<span
data-speaker={speakerWithWords.speaker}
className={`font-semibold mr-2 cursor-pointer transition-colors ${
selectedText === speakerWithWords.speaker ? "bg-amber-200 text-amber-900 rounded px-1" : "text-on-surface hover:text-primary"
}`}
>
{getSpeakerName(speakerWithWords.speaker)}:
</span>
{speakerWithWords.words.map((word: any, wIndex: number) => {
const isActive =
selectedTextIsTimeSlice(selectedText) &&
selectedText.start <= word.start &&
selectedText.end >= word.end;
return (
<span
data-start={word.start}
data-end={word.end}
key={wIndex}
className={`transition-colors cursor-text ${
isActive ? "bg-amber-200 text-amber-900 rounded px-0.5" : "text-on-surface-variant hover:text-on-surface"
}`}
>
{word.text}{" "}
</span>
);
})}
</p>
))}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,21 @@
export type TimeSlice = {
start: number;
end: number;
};
export type SelectedText = number | TimeSlice | undefined;
export function selectedTextIsSpeaker(
selectedText: SelectedText,
): selectedText is number {
return typeof selectedText === "number";
}
export function selectedTextIsTimeSlice(
selectedText: SelectedText,
): selectedText is TimeSlice {
return (
typeof (selectedText as any)?.start === "number" &&
typeof (selectedText as any)?.end === "number"
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'tertiary';
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', className = '', children, ...props }, ref) => {
const baseStyles = 'rounded-sm px-5 py-2.5 font-sans font-semibold text-sm transition-all duration-200';
const variants = {
primary: 'bg-gradient-primary text-on-primary border-none hover:brightness-110 active:brightness-95',
secondary: 'bg-transparent border-[1.5px] border-primary text-primary hover:bg-primary/5',
tertiary: 'bg-transparent border-none text-primary hover:bg-surface-mid',
};
return (
<button
ref={ref}
className={`${baseStyles} ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,19 @@
import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className = '', children, ...props }, ref) => {
return (
<div
ref={ref}
className={`bg-surface-highest rounded-md p-6 shadow-card ${className}`}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';

View File

@@ -0,0 +1,25 @@
import React from 'react';
interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className = '', label, ...props }, ref) => {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
ref={ref}
className={`appearance-none w-4 h-4 rounded-[4px] border-[1.5px] border-outline-variant/60 checked:bg-primary checked:border-primary transition-colors relative
checked:after:content-[''] checked:after:absolute checked:after:left-[4px] checked:after:top-[1px] checked:after:w-[5px] checked:after:h-[9px] checked:after:border-r-2 checked:after:border-b-2 checked:after:border-white checked:after:rotate-45
${className}`}
{...props}
/>
{label && <span className="font-sans text-sm text-on-surface">{label}</span>}
</label>
);
}
);
Checkbox.displayName = 'Checkbox';

View File

@@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import { Button } from './Button';
import { AlertTriangle, X, Trash2 } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onClose: () => void;
isDestructive?: boolean;
isLoading?: boolean;
}
export function ConfirmModal({
isOpen,
title,
description,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onClose,
isDestructive = true,
isLoading = false,
}: ConfirmModalProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isLoading) onClose();
};
if (isOpen) window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isLoading]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-[#1b1c14]/40 backdrop-blur-sm transition-opacity animate-in fade-in duration-200"
onClick={() => !isLoading && onClose()}
/>
{/* Modal Box */}
<div className="relative w-full max-w-md bg-surface shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 fade-in duration-200 border border-outline-variant/20">
<button
onClick={onClose}
className="absolute right-4 top-4 p-2 text-muted hover:text-on-surface hover:bg-surface-high rounded-full transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5" />
</button>
<div className="p-6 pt-8">
<div className="flex gap-4 items-start">
<div className={`p-3 rounded-full shrink-0 ${isDestructive ? 'bg-red-50 text-red-500' : 'bg-primary/10 text-primary'}`}>
{isDestructive ? <Trash2 className="w-6 h-6" /> : <AlertTriangle className="w-6 h-6" />}
</div>
<div className="space-y-2 mt-1 pr-6">
<h2 className="text-xl font-serif font-bold text-on-surface">{title}</h2>
<p className="text-[0.9375rem] font-sans text-on-surface-variant leading-relaxed">
{description}
</p>
</div>
</div>
</div>
<div className="p-5 bg-surface-low border-t border-outline-variant/10 flex flex-col-reverse sm:flex-row items-center justify-end gap-3 rounded-b-2xl">
<Button
variant="secondary"
className="w-full sm:w-auto px-5 py-2 hover:bg-surface-highest transition-colors"
onClick={onClose}
disabled={isLoading}
>
{cancelText}
</Button>
<Button
variant={isDestructive ? "secondary" : "primary"}
className={
isDestructive
? "w-full sm:w-auto px-5 py-2 !bg-red-50 !text-red-600 border border-red-200 hover:!bg-red-500 hover:!text-white hover:border-red-600 transition-colors shadow-sm"
: "w-full sm:w-auto px-5 py-2"
}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
interface FieldErrorProps {
message?: string;
}
export const FieldError: React.FC<FieldErrorProps> = ({ message }) => {
if (!message) return null;
return (
<span className="font-sans text-[0.8125rem] text-primary mt-1 block">
{message}
</span>
);
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className = '', ...props }, ref) => {
return (
<input
ref={ref}
className={`bg-surface-mid border border-outline-variant/40 rounded-sm px-3.5 py-2.5 font-sans text-on-surface placeholder:text-muted focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/15 transition-all ${className}`}
{...props}
/>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,19 @@
import React from 'react';
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', children, ...props }, ref) => {
return (
<select
ref={ref}
className={`bg-surface-mid border border-outline-variant/40 rounded-sm px-3.5 py-2.5 font-sans text-on-surface focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/15 transition-all appearance-none ${className}`}
{...props}
>
{children}
</select>
);
}
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,88 @@
import { useEffect, useState, useRef } from "react";
import { useError } from "../../lib/errorContext";
import type { components } from "../../lib/reflector-api";
import { shouldShowError } from "../../lib/errorUtils";
import { useRoomsCreateMeeting } from "../../lib/apiHooks";
import { ApiError } from "../../api/_error";
type Meeting = components["schemas"]["Meeting"];
type ErrorMeeting = {
error: ApiError;
loading: false;
response: null;
reload: () => void;
};
type LoadingMeeting = {
error: null;
response: null;
loading: true;
reload: () => void;
};
type SuccessMeeting = {
error: null;
response: Meeting;
loading: false;
reload: () => void;
};
const useRoomDefaultMeeting = (
roomName: string | null,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState<Meeting | null>(null);
const [reload, setReload] = useState(0);
const { setError } = useError();
const createMeetingMutation = useRoomsCreateMeeting();
const reloadHandler = () => setReload((prev) => prev + 1);
// this is to undupe dev mode room creation
const creatingRef = useRef(false);
useEffect(() => {
if (!roomName) return;
if (creatingRef.current) return;
const createMeeting = async () => {
creatingRef.current = true;
try {
const result = await createMeetingMutation.mutateAsync({
params: {
path: {
room_name: roomName,
},
},
body: {
allow_duplicated: false,
},
});
setResponse(result);
} catch (error: any) {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman && error.status !== 404) {
setError(
error,
"There was an error loading the meeting. Please try again by refreshing the page.",
);
} else {
setError(error);
}
} finally {
creatingRef.current = false;
}
};
createMeeting().catch(console.error);
}, [roomName, reload, createMeetingMutation, setError]);
const loading = createMeetingMutation.isPending && !response;
const error = createMeetingMutation.error;
return { response, loading, error, reload: reloadHandler } as
| ErrorMeeting
| LoadingMeeting
| SuccessMeeting;
};
export default useRoomDefaultMeeting;

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { useTranscriptWebRTC } from "../../lib/apiHooks";
export const useWebRTC = (stream: MediaStream | null, transcriptId: string | null): RTCPeerConnection | null => {
const [peer, setPeer] = useState<RTCPeerConnection | null>(null);
const { mutateAsync: mutateWebRtcTranscriptAsync } = useTranscriptWebRTC();
useEffect(() => {
if (!stream || !transcriptId) {
return;
}
let pc: RTCPeerConnection;
const setupConnection = async () => {
pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
// Add local audio tracks to the peer connection
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
try {
// Create an offer. Since HTTP signaling doesn't stream ICE candidates,
// we can wait for ICE gathering to complete before sending SDP.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering to complete so SDP has all local candidates
await new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") {
resolve();
} else {
const checkState = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", checkState);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", checkState);
// Fallback timeout just in case ICE STUN gathering hangs
setTimeout(() => {
pc.removeEventListener("icegatheringstatechange", checkState);
resolve();
}, 2000);
}
});
const rtcOffer = {
sdp: pc.localDescription!.sdp,
type: pc.localDescription!.type,
};
const answer = await mutateWebRtcTranscriptAsync({
params: {
path: {
transcript_id: transcriptId as any,
},
},
body: rtcOffer as any,
});
await pc.setRemoteDescription(new RTCSessionDescription(answer as RTCSessionDescriptionInit));
setPeer(pc);
} catch (err) {
console.error("Failed to establish WebRTC connection", err);
}
};
setupConnection();
return () => {
if (pc) {
pc.close();
}
setPeer(null);
};
}, [stream, transcriptId, mutateWebRtcTranscriptAsync]);
return peer;
};

View File

@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { WEBSOCKET_URL } from "../../lib/apiClient";
import { useAuth } from "../../lib/AuthProvider";
import { parseNonEmptyString } from "../../lib/utils";
import { getReconnectDelayMs, MAX_RETRIES } from "./webSocketReconnect";
import { Topic, FinalSummary, Status } from "./webSocketTypes";
import type { components, operations } from "../../lib/reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"];
type TranscriptWsEvent = operations["v1_transcript_get_websocket_events"]["responses"][200]["content"]["application/json"];
export type UseWebSockets = {
transcriptTextLive: string;
accumulatedText: string;
title: string;
topics: Topic[];
finalSummary: FinalSummary;
status: Status | null;
waveform: AudioWaveform | null;
duration: number | null;
};
export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const auth = useAuth();
const queryClient = useQueryClient();
const [transcriptTextLive, setTranscriptTextLive] = useState<string>("");
const [accumulatedText, setAccumulatedText] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [topics, setTopics] = useState<Topic[]>([]);
const [waveform, setWaveForm] = useState<AudioWaveform | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [finalSummary, setFinalSummary] = useState<FinalSummary>({ summary: "" });
const [status, setStatus] = useState<Status | null>(null);
const [textQueue, setTextQueue] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// Smooth out rapid text pushes
useEffect(() => {
if (isProcessing || textQueue.length === 0) return;
setIsProcessing(true);
const text = textQueue[0];
setTranscriptTextLive(text);
const WPM_READING = 200 + textQueue.length * 10;
const wordCount = text.split(/\s+/).length;
const delay = (wordCount / WPM_READING) * 60 * 1000;
setTimeout(() => {
setIsProcessing(false);
setTextQueue((prevQueue) => prevQueue.slice(1));
}, delay);
}, [textQueue, isProcessing]);
useEffect(() => {
if (!transcriptId) return;
const tsId = parseNonEmptyString(transcriptId);
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws: WebSocket | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let intentionalClose = false;
const connect = () => {
const subprotocols =
auth.status === "authenticated" && (auth as any).accessToken
? ["bearer", (auth as any).accessToken]
: undefined;
ws = new WebSocket(url, subprotocols);
ws.onopen = () => {
console.debug("Transcript WebSocket connected");
retryCount = 0;
};
ws.onmessage = (event) => {
try {
const message: TranscriptWsEvent = JSON.parse(event.data);
switch (message.event) {
case "TRANSCRIPT": {
const newText = (message.data.text ?? "").trim();
if (!newText) break;
setTextQueue((prev) => [...prev, newText]);
setAccumulatedText((prev) => prev + " " + newText);
break;
}
case "TOPIC":
setTopics((prevTopics) => {
const topic = message.data;
const index = prevTopics.findIndex((prev) => prev.id === topic.id);
if (index >= 0) {
prevTopics[index] = topic;
return [...prevTopics];
}
return [...prevTopics, topic];
});
break;
case "FINAL_LONG_SUMMARY":
setFinalSummary({ summary: message.data.long_summary });
break;
case "FINAL_TITLE":
setTitle(message.data.title);
break;
case "WAVEFORM":
setWaveForm({ data: message.data.waveform });
break;
case "DURATION":
setDuration(message.data.duration);
break;
case "STATUS":
setStatus(message.data as any);
if (message.data.value === "ended" || message.data.value === "error") {
intentionalClose = true;
ws?.close();
// We should invalidate standard hooks here theoretically...
// queryClient.invalidateQueries({ queryKey: ["transcript", tsId] });
}
break;
case "ACTION_ITEMS":
case "FINAL_SHORT_SUMMARY":
break;
default:
console.warn(`Unknown WebSocket event: ${(message as any).event}`);
}
} catch (error) {
console.error("Payload parse error", error);
}
};
ws.onerror = (error) => {
console.error("Transcript WebSocket error:", error);
};
ws.onclose = (event) => {
if (intentionalClose) return;
const normalCodes = [1000, 1001, 1005];
if (normalCodes.includes(event.code)) return;
if (retryCount < MAX_RETRIES) {
const delay = getReconnectDelayMs(retryCount);
retryCount++;
retryTimeout = setTimeout(connect, delay);
}
};
};
connect();
return () => {
intentionalClose = true;
if (retryTimeout) clearTimeout(retryTimeout);
ws?.close();
};
}, [transcriptId, auth.status, (auth as any).accessToken, queryClient]);
return {
transcriptTextLive,
accumulatedText,
topics,
finalSummary,
title,
status,
waveform,
duration,
};
};

View File

@@ -0,0 +1,5 @@
export const MAX_RETRIES = 10;
export function getReconnectDelayMs(retryIndex: number): number {
return Math.min(1000 * Math.pow(2, retryIndex), 30000);
}

View File

@@ -0,0 +1,24 @@
import type { components } from "../../lib/reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
export type Topic = GetTranscriptTopic;
export type TranscriptStatus = "idle" | "recording" | "uploaded" | "processing" | "ended" | "error";
export type Transcript = {
text: string;
};
export type FinalSummary = {
summary: string;
};
export type Status = {
value: TranscriptStatus;
};
export type TranslatedTopic = {
text: string;
translation: string;
};

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
const MIC_QUERY = { name: "microphone" as PermissionName };
export type AudioDeviceOption = {
value: string;
label: string;
};
export const useAudioDevice = () => {
const [permissionOk, setPermissionOk] = useState<boolean>(false);
const [permissionDenied, setPermissionDenied] = useState<boolean>(false);
const [audioDevices, setAudioDevices] = useState<AudioDeviceOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// skips on SSR
checkPermission();
}, []);
useEffect(() => {
if (permissionOk) {
updateDevices();
}
}, [permissionOk]);
const checkPermission = (): void => {
if (navigator.userAgent.includes("Firefox")) {
navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((stream) => {
setPermissionOk(true);
setPermissionDenied(false);
stream.getTracks().forEach((track) => track.stop());
})
.catch((e) => {
setPermissionOk(false);
setPermissionDenied(false);
})
.finally(() => setLoading(false));
return;
}
navigator.permissions
.query(MIC_QUERY)
.then((permissionStatus) => {
setPermissionOk(permissionStatus.state === "granted");
setPermissionDenied(permissionStatus.state === "denied");
permissionStatus.onchange = () => {
setPermissionOk(permissionStatus.state === "granted");
setPermissionDenied(permissionStatus.state === "denied");
};
})
.catch(() => {
setPermissionOk(false);
setPermissionDenied(false);
})
.finally(() => {
setLoading(false);
});
};
const requestPermission = () => {
navigator.mediaDevices
.getUserMedia({
audio: true,
})
.then((stream) => {
if (!navigator.userAgent.includes("Firefox"))
stream.getTracks().forEach((track) => track.stop());
setPermissionOk(true);
setPermissionDenied(false);
})
.catch(() => {
setPermissionDenied(true);
setPermissionOk(false);
})
.finally(() => {
setLoading(false);
});
};
const getAudioStream = async (
deviceId: string,
): Promise<MediaStream | null> => {
try {
const urlParams = new URLSearchParams(window.location.search);
const noiseSuppression = urlParams.get("noiseSuppression") === "true";
const echoCancellation = urlParams.get("echoCancellation") === "true";
console.debug(
"noiseSuppression",
noiseSuppression,
"echoCancellation",
echoCancellation,
);
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId,
noiseSuppression,
echoCancellation,
},
});
return stream;
} catch (e) {
setPermissionOk(false);
setAudioDevices([]);
return null;
}
};
const updateDevices = async (): Promise<void> => {
const devices = await navigator.mediaDevices.enumerateDevices();
const _audioDevices = devices
.filter(
(d) => d.kind === "audioinput" && d.deviceId != "" && d.label != "",
)
.map((d) => ({ value: d.deviceId, label: d.label }));
setPermissionOk(_audioDevices.length > 0);
setAudioDevices(_audioDevices);
};
return {
loading,
permissionOk,
permissionDenied,
audioDevices,
getAudioStream,
requestPermission,
};
};

View File

@@ -0,0 +1,271 @@
/**
* AuthProvider — Vite-compatible replacement for next-auth.
*
* Communicates with the Express auth proxy server for:
* - Session checking (GET /auth/session)
* - Login (POST /auth/login for credentials, GET /auth/login for SSO)
* - Token refresh (POST /auth/refresh)
* - Logout (POST /auth/logout)
*/
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
import { configureApiAuth } from "./apiClient";
// ─── Types ───────────────────────────────────────────────────────────────────
interface AuthUser {
id: string;
name?: string | null;
email?: string | null;
}
type AuthContextType = (
| { status: "loading" }
| { status: "unauthenticated"; error?: string }
| {
status: "authenticated";
accessToken: string;
accessTokenExpires: number;
user: AuthUser;
}
) & {
signIn: (
method: "credentials" | "sso",
credentials?: { email: string; password: string },
) => Promise<{ ok: boolean; error?: string }>;
signOut: () => Promise<void>;
update: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// ─── Constants ───────────────────────────────────────────────────────────────
const AUTH_PROXY_BASE =
import.meta.env.VITE_AUTH_PROXY_URL || "/auth";
// 4 minutes — must refresh before token expires
const REFRESH_BEFORE_MS = 4 * 60 * 1000;
// Poll every 5 seconds for refresh check
const REFRESH_INTERVAL_MS = 5000;
// ─── Provider ────────────────────────────────────────────────────────────────
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<
| { status: "loading" }
| { status: "unauthenticated"; error?: string }
| {
status: "authenticated";
accessToken: string;
accessTokenExpires: number;
user: AuthUser;
}
>({ status: "loading" });
const refreshTimerRef = useRef<number | null>(null);
// ── Check session on mount ────────────────────────────────────────────────
const checkSession = useCallback(async () => {
try {
const res = await fetch(`${AUTH_PROXY_BASE}/session`, {
credentials: "include",
});
if (!res.ok) {
setState({ status: "unauthenticated" });
configureApiAuth(null);
return;
}
const data = await res.json();
if (data.status === "authenticated") {
setState({
status: "authenticated",
accessToken: data.accessToken,
accessTokenExpires: data.accessTokenExpires,
user: data.user,
});
configureApiAuth(data.accessToken);
} else if (data.status === "refresh_needed") {
// Try to refresh
await refreshToken();
} else {
setState({ status: "unauthenticated" });
configureApiAuth(null);
}
} catch (error) {
console.error("Session check failed:", error);
setState({ status: "unauthenticated" });
configureApiAuth(null);
}
}, []);
// ── Token refresh ─────────────────────────────────────────────────────────
const refreshToken = useCallback(async () => {
try {
const res = await fetch(`${AUTH_PROXY_BASE}/refresh`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
setState({ status: "unauthenticated" });
configureApiAuth(null);
return;
}
const data = await res.json();
setState({
status: "authenticated",
accessToken: data.accessToken,
accessTokenExpires: data.accessTokenExpires,
user: data.user,
});
configureApiAuth(data.accessToken);
} catch (error) {
console.error("Token refresh failed:", error);
setState({ status: "unauthenticated" });
configureApiAuth(null);
}
}, []);
// ── Auto-refresh polling ─────────────────────────────────────────────────
useEffect(() => {
checkSession();
}, [checkSession]);
useEffect(() => {
if (state.status !== "authenticated") {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
refreshTimerRef.current = null;
}
return;
}
const interval = window.setInterval(() => {
if (state.status !== "authenticated") return;
const timeLeft = state.accessTokenExpires - Date.now();
if (timeLeft < REFRESH_BEFORE_MS) {
refreshToken();
}
}, REFRESH_INTERVAL_MS);
refreshTimerRef.current = interval;
return () => clearInterval(interval);
}, [state.status, state.status === "authenticated" ? state.accessTokenExpires : null, refreshToken]);
// ── Sign in ───────────────────────────────────────────────────────────────
const signIn = useCallback(
async (
method: "credentials" | "sso",
credentials?: { email: string; password: string },
): Promise<{ ok: boolean; error?: string }> => {
if (method === "sso") {
// Redirect to Authentik SSO via the auth proxy
window.location.href = `${AUTH_PROXY_BASE}/login`;
return { ok: true };
}
// Credentials login
if (!credentials) {
return { ok: false, error: "Email and password are required" };
}
try {
const res = await fetch(`${AUTH_PROXY_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(credentials),
});
console.log(res)
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return { ok: false, error: data.error || "Invalid credentials" };
}
const data = await res.json();
setState({
status: "authenticated",
accessToken: data.accessToken,
accessTokenExpires: data.accessTokenExpires,
user: data.user,
});
configureApiAuth(data.accessToken);
return { ok: true };
} catch (error) {
console.error("Login error:", error);
return { ok: false, error: "An unexpected error occurred" };
}
},
[],
);
// ── Sign out ──────────────────────────────────────────────────────────────
const signOut = useCallback(async () => {
try {
await fetch(`${AUTH_PROXY_BASE}/logout`, {
method: "POST",
credentials: "include",
});
} catch (error) {
console.error("Logout error:", error);
}
setState({ status: "unauthenticated" });
configureApiAuth(null);
}, []);
// ── Update (re-check session) ─────────────────────────────────────────────
const update = useCallback(async () => {
await checkSession();
}, [checkSession]);
// ── Sync configureApiAuth ────────────────────────────────────────────────
// Not useEffect — we need the token set ASAP, not on next render
configureApiAuth(
state.status === "authenticated"
? state.accessToken
: state.status === "loading"
? undefined
: null,
);
const contextValue: AuthContextType = {
...state,
signIn,
signOut,
update,
};
return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
);
}
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,186 @@
/**
* UserEventsProvider — ported from Next.js app/lib/UserEventsProvider.tsx
*
* Connects to the backend WebSocket for real-time transcript updates.
* Invalidates React Query caches when events are received.
*/
import React, { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { WEBSOCKET_URL } from "./apiClient";
import { useAuth } from "./AuthProvider";
import { invalidateTranscript, invalidateTranscriptLists } from "./apiHooks";
import { parseNonEmptyString } from "./utils";
import type { operations } from "./reflector-api";
type UserWsEvent =
operations["v1_user_get_websocket_events"]["responses"][200]["content"]["application/json"];
class UserEventsStore {
private socket: WebSocket | null = null;
private listeners: Set<(event: MessageEvent) => void> = new Set();
private closeTimeoutId: number | null = null;
private isConnecting = false;
ensureConnection(url: string, subprotocols?: string[]) {
if (typeof window === "undefined") return;
if (this.closeTimeoutId !== null) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = null;
}
if (this.isConnecting) return;
if (
this.socket &&
(this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING)
) {
return;
}
this.isConnecting = true;
const ws = new WebSocket(url, subprotocols || []);
this.socket = ws;
ws.onmessage = (event: MessageEvent) => {
this.listeners.forEach((listener) => {
try {
listener(event);
} catch (err) {
console.error("UserEvents listener error", err);
}
});
};
ws.onopen = () => {
if (this.socket === ws) this.isConnecting = false;
};
ws.onclose = () => {
if (this.socket === ws) {
this.socket = null;
this.isConnecting = false;
}
};
ws.onerror = () => {
if (this.socket === ws) this.isConnecting = false;
};
}
subscribe(listener: (event: MessageEvent) => void): () => void {
this.listeners.add(listener);
if (this.closeTimeoutId !== null) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = null;
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.closeTimeoutId = window.setTimeout(() => {
if (this.socket) {
try {
this.socket.close();
} catch (err) {
console.warn("Error closing user events socket", err);
}
}
this.socket = null;
this.closeTimeoutId = null;
}, 1000);
}
};
}
}
const sharedStore = new UserEventsStore();
export function UserEventsProvider({
children,
}: {
children: React.ReactNode;
}) {
const auth = useAuth();
const queryClient = useQueryClient();
const tokenRef = useRef<string | null>(null);
const detachRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Only tear down when the user is truly unauthenticated
if (auth.status === "unauthenticated") {
if (detachRef.current) {
try {
detachRef.current();
} catch (err) {
console.warn("Error detaching UserEvents listener", err);
}
detachRef.current = null;
}
tokenRef.current = null;
return;
}
// During loading, keep the existing connection intact
if (auth.status !== "authenticated") {
return;
}
// Authenticated: pin the initial token for the lifetime of this WS connection
if (!tokenRef.current && auth.accessToken) {
tokenRef.current = auth.accessToken;
}
const pinnedToken = tokenRef.current;
const url = `${WEBSOCKET_URL}/v1/events`;
// Ensure a single shared connection
sharedStore.ensureConnection(
url,
pinnedToken ? ["bearer", pinnedToken] : undefined,
);
// Subscribe once; avoid re-subscribing during transient status changes
if (!detachRef.current) {
const onMessage = (event: MessageEvent) => {
try {
const msg: UserWsEvent = JSON.parse(event.data);
switch (msg.event) {
case "TRANSCRIPT_CREATED":
case "TRANSCRIPT_DELETED":
case "TRANSCRIPT_STATUS":
case "TRANSCRIPT_FINAL_TITLE":
case "TRANSCRIPT_DURATION":
invalidateTranscriptLists(queryClient).then(() => {});
invalidateTranscript(
queryClient,
parseNonEmptyString(msg.data.id),
).then(() => {});
break;
default: {
const _exhaustive: never = msg;
console.warn(
`Unknown user event: ${(_exhaustive as UserWsEvent).event}`,
);
}
}
} catch (err) {
console.warn("Invalid user event message", event.data);
}
};
const unsubscribe = sharedStore.subscribe(onMessage);
detachRef.current = unsubscribe;
}
}, [auth.status, queryClient]);
// On unmount, detach the listener and clear the pinned token
useEffect(() => {
return () => {
if (detachRef.current) {
try {
detachRef.current();
} catch (err) {
console.warn("Error detaching UserEvents listener on unmount", err);
}
detachRef.current = null;
}
tokenRef.current = null;
};
}, []);
return <>{children}</>;
}

View File

@@ -0,0 +1,94 @@
/**
* API Client — ported from Next.js app/lib/apiClient.tsx
*
* Uses openapi-fetch + openapi-react-query for type-safe API calls.
* Token management delegated to configureApiAuth().
*/
import createClient from "openapi-fetch";
import type { paths } from "./reflector-api";
import createFetchClient from "openapi-react-query";
import { parseNonEmptyString, parseMaybeNonEmptyString } from "./utils";
// ─── URL Resolution ──────────────────────────────────────────────────────────
const resolveApiUrl = (): string => {
const envUrl = import.meta.env.VITE_API_URL;
if (envUrl) return envUrl;
// Default: assume API is accessed via proxy on same origin.
// OpenAPI spec paths already include /v1 prefix, so base is just "/".
return "/";
};
export const API_URL = resolveApiUrl();
/**
* Derive a WebSocket URL from the API_URL.
* Handles full URLs (http://host/api, https://host/api) and relative paths (/api).
*/
const deriveWebSocketUrl = (apiUrl: string): string => {
if (typeof window === "undefined") {
return "ws://localhost";
}
const parsed = new URL(apiUrl, window.location.origin);
const wsProtocol = parsed.protocol === "https:" ? "wss:" : "ws:";
const pathname = parsed.pathname.replace(/\/+$/, "");
return `${wsProtocol}//${parsed.host}${pathname}`;
};
const resolveWebSocketUrl = (): string => {
const raw = import.meta.env.VITE_WEBSOCKET_URL;
if (!raw || raw === "auto") {
return deriveWebSocketUrl(API_URL);
}
return raw;
};
export const WEBSOCKET_URL = resolveWebSocketUrl();
// ─── Client Setup ────────────────────────────────────────────────────────────
export const client = createClient<paths>({
baseUrl: API_URL,
});
let currentAuthToken: string | null | undefined = undefined;
// Auth middleware — attaches Bearer token to every request
client.use({
async onRequest({ request }) {
const token = currentAuthToken;
if (token) {
request.headers.set(
"Authorization",
`Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
);
}
// Don't override Content-Type for FormData (file uploads set their own boundary)
if (
!request.headers.has("Content-Type") &&
!(request.body instanceof FormData)
) {
request.headers.set("Content-Type", "application/json");
}
return request;
},
});
export const $api = createFetchClient<paths>(client);
/**
* Set the auth token used for API requests.
* Called by the AuthProvider whenever auth state changes.
*
* Contract: lightweight, idempotent
* - undefined = "still loading / unknown"
* - null = "definitely logged out"
* - string = "access token"
*/
export const configureApiAuth = (token: string | null | undefined) => {
// Watch only for the initial loading; "reloading" state assumes token
// presence/absence
if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token;
};

View File

@@ -0,0 +1,967 @@
/**
* API Hooks — ported from Next.js app/lib/apiHooks.ts
*
* ~40 hooks covering Rooms, Transcripts, Meetings, Participants,
* Topics, Zulip, Config, API Keys, WebRTC, etc.
*
* Adaptations from Next.js version:
* - Removed "use client" directives
* - Replaced useError from Next.js ErrorProvider with our errorContext
* - useAuth comes from our AuthProvider (not next-auth)
*/
import { $api } from "./apiClient";
import { useError } from "./errorContext";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "./reflector-api";
import { useAuth } from "./AuthProvider";
import { MeetingId } from "./types";
import { NonEmptyString } from "./utils";
// ─── Transcript status types ─────────────────────────────────────────────────
type TranscriptStatus = "processing" | "uploaded" | "recording" | "processed" | "error";
// ─── Auth readiness ──────────────────────────────────────────────────────────
export const useAuthReady = () => {
const auth = useAuth();
return {
isAuthenticated: auth.status === "authenticated",
isLoading: auth.status === "loading",
};
};
// ─── Rooms ───────────────────────────────────────────────────────────────────
export function useRoomsList(page: number = 1) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms",
{
params: {
query: { page },
},
},
{
enabled: isAuthenticated,
},
);
}
export function useRoomGet(roomId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_id}",
{
params: {
path: { room_id: roomId! },
},
},
{
enabled: !!roomId && isAuthenticated,
},
);
}
export function useRoomGetByName(roomName: string | null) {
return $api.useQuery(
"get",
"/v1/rooms/name/{room_name}",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName,
},
);
}
export function useRoomCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the room");
},
});
}
export function useRoomUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", "/v1/rooms/{room_id}", {
onSuccess: async (room) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", {
params: {
path: {
room_id: room.id,
},
},
}).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error updating the room");
},
});
}
export function useRoomDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/rooms/{room_id}", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the room");
},
});
}
export function useRoomTestWebhook() {
const { setError } = useError();
return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", {
onError: (error) => {
setError(error as Error, "There was an error testing the webhook");
},
});
}
// ─── Transcripts ─────────────────────────────────────────────────────────────
type SourceKind = components["schemas"]["SourceKind"];
export const TRANSCRIPT_SEARCH_URL = "/v1/transcripts/search" as const;
export const invalidateTranscriptLists = (queryClient: QueryClient) =>
queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
export function useTranscriptsSearch(
q: string = "",
options: {
limit?: number;
offset?: number;
room_id?: string;
source_kind?: SourceKind;
} = {},
) {
return $api.useQuery(
"get",
TRANSCRIPT_SEARCH_URL,
{
params: {
query: {
q,
limit: options.limit,
offset: options.offset,
room_id: options.room_id,
source_kind: options.source_kind,
},
},
},
{
enabled: true,
},
);
}
export function useTranscriptGet(transcriptId: NonEmptyString | null) {
const ACTIVE_TRANSCRIPT_STATUSES = new Set<TranscriptStatus>([
"processing",
"uploaded",
"recording",
]);
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: {
transcript_id: transcriptId!,
},
},
},
{
enabled: !!transcriptId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && ACTIVE_TRANSCRIPT_STATUSES.has(status as TranscriptStatus) ? 5000 : false;
},
},
);
}
export const invalidateTranscript = (
queryClient: QueryClient,
transcriptId: NonEmptyString,
) =>
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
params: { path: { transcript_id: transcriptId } },
}).queryKey,
});
export function useTranscriptCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the transcript");
},
});
}
export function useTranscriptDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the transcript");
},
});
}
export function useTranscriptUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
onSuccess: (_data, variables) => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
}).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error updating the transcript");
},
});
}
export function useTranscriptProcess() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", {
onError: (error) => {
setError(error as Error, "There was an error processing the transcript");
},
});
}
// ─── Transcript Topics ───────────────────────────────────────────────────────
export function useTranscriptTopics(transcriptId: NonEmptyString | null) {
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId,
},
);
}
export const invalidateTranscriptTopics = (
queryClient: QueryClient,
transcriptId: NonEmptyString,
) =>
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/topics",
{
params: { path: { transcript_id: transcriptId } },
},
).queryKey,
});
export function useTranscriptTopicsWithWords(
transcriptId: NonEmptyString | null,
) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics/with-words",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptTopicsWithWordsPerSpeaker(
transcriptId: NonEmptyString | null,
topicId: string | null,
) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
{
params: {
path: {
transcript_id: transcriptId!,
topic_id: topicId!,
},
},
},
{
enabled: !!transcriptId && !!topicId && isAuthenticated,
},
);
}
// ─── Transcript Audio ────────────────────────────────────────────────────────
export function useTranscriptWaveform(transcriptId: NonEmptyString | null) {
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/waveform",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId,
retry: false,
},
);
}
export const invalidateTranscriptWaveform = (
queryClient: QueryClient,
transcriptId: NonEmptyString,
) =>
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/audio/waveform",
{
params: { path: { transcript_id: transcriptId } },
},
).queryKey,
});
export function useTranscriptMP3(transcriptId: NonEmptyString | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/mp3",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptUploadAudio() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/record/upload",
{
onSuccess: (_data, variables) => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error uploading the audio file");
},
},
);
}
// ─── Transcript Participants ─────────────────────────────────────────────────
export function useTranscriptParticipants(transcriptId: NonEmptyString | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: transcriptId! },
},
},
{
enabled: !!transcriptId && isAuthenticated,
},
);
}
export function useTranscriptParticipantUpdate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (_data, variables) => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error updating the participant");
},
},
);
}
export function useTranscriptParticipantCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/participants",
{
onSuccess: (_data, variables) => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the participant");
},
},
);
}
export function useTranscriptParticipantDelete() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"delete",
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (_data, variables) => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error deleting the participant");
},
},
);
}
// ─── Transcript Speaker Management ──────────────────────────────────────────
export function useTranscriptSpeakerAssign() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/speaker/assign",
{
onSuccess: (_data, variables) => {
return Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error assigning the speaker");
},
},
);
}
export function useTranscriptSpeakerMerge() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation(
"patch",
"/v1/transcripts/{transcript_id}/speaker/merge",
{
onSuccess: (_data, variables) => {
return Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error merging speakers");
},
},
);
}
// ─── Transcript Sharing ──────────────────────────────────────────────────────
export function useTranscriptPostToZulip() {
const { setError } = useError();
// @ts-ignore - Zulip endpoint not in OpenAPI spec
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", {
onError: (error) => {
setError(error as Error, "There was an error posting to Zulip");
},
});
}
export function useTranscriptSendEmail() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/email", {
onError: (error) => {
setError(error as Error, "There was an error sending the email");
},
});
}
// ─── Transcript WebRTC ───────────────────────────────────────────────────────
export function useTranscriptWebRTC() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/transcripts/{transcript_id}/record/webrtc",
{
onError: (error) => {
setError(error as Error, "There was an error with WebRTC connection");
},
},
);
}
// ─── Meetings ────────────────────────────────────────────────────────────────
const MEETINGS_PATH_PARTIAL = "meetings" as const;
const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const;
const MEETINGS_UPCOMING_PATH_PARTIAL =
`${MEETINGS_PATH_PARTIAL}/upcoming` as const;
const MEETING_LIST_PATH_PARTIALS = [
MEETINGS_ACTIVE_PATH_PARTIAL,
MEETINGS_UPCOMING_PATH_PARTIAL,
];
export function useRoomsCreateMeeting() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
onSuccess: async (_data, variables) => {
const roomName = variables.params.path.room_name;
await Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/rooms/{room_name}/meetings/active" as `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName },
},
},
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error creating the meeting");
},
});
}
export function useRoomActiveMeetings(roomName: string | null) {
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/active" as `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName,
},
);
}
export function useRoomUpcomingMeetings(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/upcoming" as `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
export function useRoomGetMeeting(
roomName: string | null,
meetingId: MeetingId | null,
) {
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/{meeting_id}",
{
params: {
path: {
room_name: roomName!,
meeting_id: meetingId!,
},
},
},
{
enabled: !!roomName && !!meetingId,
},
);
}
export function useRoomJoinMeeting() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/join",
{
onError: (error) => {
setError(error as Error, "There was an error joining the meeting");
},
},
);
}
export function useMeetingStartRecording() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/meetings/{meeting_id}/recordings/start",
{
onError: (error) => {
setError(error as Error, "Failed to start recording");
},
},
);
}
export function useMeetingAudioConsent() {
const { setError } = useError();
return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", {
onError: (error) => {
setError(error as Error, "There was an error recording consent");
},
});
}
export function useMeetingAddEmailRecipient() {
const { setError } = useError();
return $api.useMutation("post", "/v1/meetings/{meeting_id}/email-recipient", {
onError: (error) => {
setError(error as Error, "There was an error adding the email");
},
});
}
export function useMeetingDeactivate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", `/v1/meetings/{meeting_id}/deactivate`, {
onError: (error) => {
setError(error as Error, "Failed to end meeting");
},
onSuccess: () => {
return queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key.some(
(k) =>
typeof k === "string" &&
!!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)),
);
},
});
},
});
}
// ─── API Keys ──────────────────────────────────────────────────────────────────
export function useApiKeysList() {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/user/api-keys",
{},
{
enabled: isAuthenticated,
},
);
}
export function useApiKeyCreate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/user/api-keys", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/user/api-keys").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error creating the API key");
},
});
}
export function useApiKeyRevoke() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("delete", "/v1/user/api-keys/{key_id}", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/user/api-keys").queryKey,
});
},
onError: (error) => {
setError(error as Error, "There was an error rewoking the API key");
},
});
}
// ─── Config ──────────────────────────────────────────────────────────────────
export function useConfig() {
return $api.useQuery("get", "/v1/config", {});
}
// ─── Zulip ───────────────────────────────────────────────────────────────────
export function useZulipStreams(enabled: boolean = true) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/zulip/streams",
{},
{
enabled: enabled && isAuthenticated,
},
);
}
export function useZulipTopics(streamId: number | null) {
const { isAuthenticated } = useAuthReady();
const enabled = !!streamId && isAuthenticated;
return $api.useQuery(
"get",
"/v1/zulip/streams/{stream_id}/topics",
{
params: {
path: {
stream_id: enabled ? streamId : 0,
},
},
},
{
enabled,
},
);
}
// ─── Calendar / ICS ──────────────────────────────────────────────────────────
export function useRoomIcsSync() {
const { setError } = useError();
return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", {
onError: (error) => {
setError(error as Error, "There was an error syncing the calendar");
},
});
}
export function useRoomIcsStatus(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/ics/status",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
export function useRoomCalendarEvents(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}

View File

@@ -0,0 +1,12 @@
export type NonEmptyArray<T> = [T, ...T[]];
export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> =>
arr.length > 0;
export const assertNonEmptyArray = <T>(
arr: T[],
err?: string,
): NonEmptyArray<T> => {
if (isNonEmptyArray(arr)) {
return arr;
}
throw new Error(err ?? "Expected non-empty array");
};

View File

@@ -0,0 +1,45 @@
/**
* ConsentDialog — ported from Next.js, restyled with Tailwind.
*/
import { useState, useEffect, useRef } from "react";
import { CONSENT_DIALOG_TEXT } from "./constants";
interface ConsentDialogProps {
onAccept: () => void;
onReject: () => void;
}
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
const acceptButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Auto-focus accept button so Escape key works
acceptButtonRef.current?.focus();
}, []);
return (
<div className="p-6 bg-white/90 backdrop-blur-sm rounded-lg shadow-lg max-w-md mx-auto">
<div className="flex flex-col items-center gap-4">
<p className="text-base text-center font-medium text-on-surface">
{CONSENT_DIALOG_TEXT.question}
</p>
<div className="flex items-center gap-4 justify-center">
<button
onClick={onReject}
className="px-4 py-2 text-sm text-on-surface-variant hover:bg-surface-mid rounded-sm transition-colors"
>
{CONSENT_DIALOG_TEXT.rejectButton}
</button>
<button
ref={acceptButtonRef}
onClick={onAccept}
className="px-4 py-2 text-sm font-semibold text-white bg-gradient-primary rounded-sm hover:brightness-110 active:brightness-95 transition-all"
>
{CONSENT_DIALOG_TEXT.acceptButton}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* ConsentDialogButton — floating "Meeting is being recorded" button.
* Restyled from Chakra to Tailwind.
*/
import { CONSENT_DIALOG_TEXT, CONSENT_BUTTON_TOP_OFFSET, CONSENT_BUTTON_LEFT_OFFSET, CONSENT_BUTTON_Z_INDEX } from "./constants";
interface ConsentDialogButtonProps {
onClick: () => void;
}
export function ConsentDialogButton({ onClick }: ConsentDialogButtonProps) {
return (
<button
onClick={onClick}
className="fixed flex items-center gap-2 px-3 py-2 bg-red-500 text-white text-xs font-semibold rounded-sm shadow-md hover:bg-red-600 active:bg-red-700 transition-colors animate-pulse"
style={{
top: CONSENT_BUTTON_TOP_OFFSET,
left: CONSENT_BUTTON_LEFT_OFFSET,
zIndex: CONSENT_BUTTON_Z_INDEX,
}}
>
<span className="w-2 h-2 rounded-full bg-white animate-ping" />
{CONSENT_DIALOG_TEXT.triggerButton}
</button>
);
}

View File

@@ -0,0 +1,15 @@
/**
* RecordingIndicator — visual indicator that a meeting is being recorded.
*/
export function RecordingIndicator() {
return (
<div className="flex items-center gap-1.5 text-red-500 text-xs font-medium">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
Recording
</div>
);
}

View File

@@ -0,0 +1,12 @@
export const CONSENT_BUTTON_TOP_OFFSET = "56px";
export const CONSENT_BUTTON_LEFT_OFFSET = "8px";
export const CONSENT_BUTTON_Z_INDEX = 1000;
export const TOAST_CHECK_INTERVAL_MS = 100;
export const CONSENT_DIALOG_TEXT = {
question:
"Can we have your permission to store this meeting's audio recording on our servers?",
acceptButton: "Yes, store the audio",
rejectButton: "No, delete after transcription",
triggerButton: "Meeting is being recorded",
} as const;

View File

@@ -0,0 +1,7 @@
export { ConsentDialogButton } from "./ConsentDialogButton";
export { ConsentDialog } from "./ConsentDialog";
export { RecordingIndicator } from "./RecordingIndicator";
export { useConsentDialog } from "./useConsentDialog";
export { recordingTypeRequiresConsent } from "./utils";
export * from "./constants";
export * from "./types";

View File

@@ -0,0 +1,14 @@
import { MeetingId } from "../types";
export type ConsentDialogResult = {
showConsentModal: () => void;
consentState: {
ready: boolean;
consentForMeetings?: Map<MeetingId, boolean>;
};
hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: MeetingId) => boolean;
consentLoading: boolean;
showRecordingIndicator: boolean;
showConsentButton: boolean;
};

View File

@@ -0,0 +1,82 @@
/**
* useConsentDialog — ported from Next.js, adapted for Tailwind-based UI.
*
* Shows consent dialog as a modal overlay instead of Chakra toast.
*/
import { useCallback, useState } from "react";
import { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent } from "../apiHooks";
import { recordingTypeRequiresConsent } from "./utils";
import type { ConsentDialogResult } from "./types";
import { MeetingId } from "../types";
import type { components } from "../reflector-api";
type Meeting = components["schemas"]["Meeting"];
type UseConsentDialogParams = {
meetingId: MeetingId;
recordingType: Meeting["recording_type"];
skipConsent: boolean;
};
export function useConsentDialog({
meetingId,
recordingType,
skipConsent,
}: UseConsentDialogParams): ConsentDialogResult {
const {
state: consentState,
touch,
hasAnswered,
hasAccepted,
} = useRecordingConsent();
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
async (given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: { meeting_id: meetingId },
},
body: {
consent_given: given,
},
});
touch(meetingId, given);
} catch (error) {
console.error("Error submitting consent:", error);
}
setModalOpen(false);
},
[audioConsentMutation, touch, meetingId],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
}, [modalOpen]);
const requiresConsent = Boolean(
recordingType && recordingTypeRequiresConsent(recordingType),
);
const showRecordingIndicator =
requiresConsent && (skipConsent || hasAccepted(meetingId));
const showConsentButton =
requiresConsent && !skipConsent && !hasAnswered(meetingId);
return {
showConsentModal,
consentState,
hasAnswered,
hasAccepted,
consentLoading: audioConsentMutation.isPending,
showRecordingIndicator,
showConsentButton,
};
}

View File

@@ -0,0 +1,10 @@
import type { components } from "../reflector-api";
type RecordingType = components["schemas"]["Meeting"]["recording_type"];
export const recordingTypeRequiresConsent = (
recordingType: RecordingType,
): boolean => {
const rt = recordingType as string;
return rt === "cloud" || rt === "raw-tracks";
};

View File

@@ -0,0 +1,91 @@
/**
* Error context — Vite-compatible replacement for the Next.js ErrorProvider.
* Provides a setError(error, message) function used by API mutation hooks.
*/
import React, { createContext, useContext, useState, useCallback } from "react";
interface ErrorState {
error: Error | null;
message: string | null;
}
interface ErrorContextValue {
errorState: ErrorState;
setError: (error: Error, message?: string) => void;
clearError: () => void;
}
const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);
export function ErrorProvider({ children }: { children: React.ReactNode }) {
const [errorState, setErrorState] = useState<ErrorState>({
error: null,
message: null,
});
const setError = useCallback((error: Error, message?: string) => {
console.error(message || "An error occurred:", error);
setErrorState({ error, message: message || error.message });
// Auto-dismiss after 8 seconds
setTimeout(() => {
setErrorState((prev) =>
prev.error === error ? { error: null, message: null } : prev,
);
}, 8000);
}, []);
const clearError = useCallback(() => {
setErrorState({ error: null, message: null });
}, []);
return React.createElement(
ErrorContext.Provider,
{ value: { errorState, setError, clearError } },
children,
// Render error toast if there's an active error
errorState.message
? React.createElement(
"div",
{
className:
"fixed bottom-6 right-6 z-[9999] max-w-md bg-red-50 border border-red-200 text-red-800 px-5 py-4 rounded-md shadow-lg animate-in slide-in-from-bottom-4 flex items-start gap-3",
role: "alert",
},
React.createElement(
"div",
{ className: "flex-1" },
React.createElement(
"p",
{ className: "text-sm font-semibold" },
"Error",
),
React.createElement(
"p",
{ className: "text-sm mt-0.5 text-red-700" },
errorState.message,
),
),
React.createElement(
"button",
{
onClick: clearError,
className:
"text-red-400 hover:text-red-600 text-lg leading-none mt-0.5",
"aria-label": "Dismiss error",
},
"×",
),
)
: null,
);
}
export function useError() {
const context = useContext(ErrorContext);
if (context === undefined) {
throw new Error("useError must be used within an ErrorProvider");
}
return context;
}

View File

@@ -0,0 +1,49 @@
import { isNonEmptyArray, NonEmptyArray } from "./array";
export function shouldShowError(error: Error | null | undefined) {
if (
error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403)
)
return false;
if (error?.name == "FetchError") return false;
return true;
}
const defaultMergeErrors = (ex: NonEmptyArray<unknown>): unknown => {
try {
return new Error(
ex
.map((e) =>
e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
)
.join("\n"),
);
} catch (e) {
console.error("Error merging errors:", e);
return ex[0];
}
};
type ReturnTypes<T extends readonly (() => any)[]> = {
[K in keyof T]: T[K] extends () => infer R ? R : never;
};
// sequence semantic for "throws"
// calls functions passed and collects its thrown values
export function sequenceThrows<Fns extends readonly (() => any)[]>(
...fs: Fns
): ReturnTypes<Fns> {
const results: unknown[] = [];
const errors: unknown[] = [];
for (const f of fs) {
try {
results.push(f());
} catch (e) {
errors.push(e);
}
}
if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray<unknown>);
return results as ReturnTypes<Fns>;
}

View File

@@ -0,0 +1,44 @@
/**
* Feature flag system — ported from Next.js app/lib/features.ts
* Uses Vite env vars instead of server-side data-env injection.
*/
export const FEATURES = [
"requireLogin",
"privacy",
"browse",
"sendToZulip",
"rooms",
"emailTranscript",
] as const;
export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly<Record<FeatureName, boolean>>;
export const DEFAULT_FEATURES: Features = {
requireLogin: true,
privacy: true,
browse: true,
sendToZulip: true,
rooms: true,
emailTranscript: false,
} as const;
const FEATURE_TO_ENV: Record<FeatureName, string> = {
requireLogin: "VITE_FEATURE_REQUIRE_LOGIN",
privacy: "VITE_FEATURE_PRIVACY",
browse: "VITE_FEATURE_BROWSE",
sendToZulip: "VITE_FEATURE_SEND_TO_ZULIP",
rooms: "VITE_FEATURE_ROOMS",
emailTranscript: "VITE_FEATURE_EMAIL_TRANSCRIPT",
};
export const featureEnabled = (featureName: FeatureName): boolean => {
const envKey = FEATURE_TO_ENV[featureName];
const envValue = import.meta.env[envKey];
if (envValue === undefined || envValue === null || envValue === "") {
return DEFAULT_FEATURES[featureName];
}
return envValue === "true";
};

View File

@@ -0,0 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});

View File

@@ -0,0 +1,153 @@
/**
* RecordingConsentProvider — ported from Next.js app/recordingConsentContext.tsx
*
* Manages per-meeting audio recording consent state in localStorage.
*/
import React, { createContext, useContext, useEffect, useState } from "react";
import { MeetingId } from "./types";
type ConsentMap = Map<MeetingId, boolean>;
type ConsentContextState =
| { ready: false }
| {
ready: true;
consentForMeetings: ConsentMap;
};
interface RecordingConsentContextValue {
state: ConsentContextState;
touch: (meetingId: MeetingId, accepted: boolean) => void;
hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: MeetingId) => boolean;
}
const RecordingConsentContext = createContext<
RecordingConsentContextValue | undefined
>(undefined);
export const useRecordingConsent = () => {
const context = useContext(RecordingConsentContext);
if (!context) {
throw new Error(
"useRecordingConsent must be used within RecordingConsentProvider",
);
}
return context;
};
const LOCAL_STORAGE_KEY = "recording_consent_meetings";
const ACCEPTED = "T" as const;
const REJECTED = "F" as const;
type Consent = typeof ACCEPTED | typeof REJECTED;
const SEPARATOR = "|" as const;
type Entry = `${MeetingId}${typeof SEPARATOR}${Consent}`;
type EntryAndDefault = Entry | MeetingId;
const encodeEntry = (meetingId: MeetingId, accepted: boolean): Entry =>
`${meetingId}|${accepted ? ACCEPTED : REJECTED}`;
const decodeEntry = (
entry: EntryAndDefault,
): { meetingId: MeetingId; accepted: boolean } | null => {
const pipeIndex = entry.lastIndexOf(SEPARATOR);
if (pipeIndex === -1) {
// Legacy format: no pipe means accepted (backward compat)
return { meetingId: entry as MeetingId, accepted: true };
}
const suffix = entry.slice(pipeIndex + 1);
const meetingId = entry.slice(0, pipeIndex) as MeetingId;
const accepted = suffix !== REJECTED;
return { meetingId, accepted };
};
export const RecordingConsentProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const [state, setState] = useState<ConsentContextState>({ ready: false });
const safeWriteToStorage = (consentMap: ConsentMap): void => {
try {
if (typeof window !== "undefined" && window.localStorage) {
const entries = Array.from(consentMap.entries())
.slice(-5)
.map(([id, accepted]) => encodeEntry(id, accepted));
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries));
}
} catch (error) {
console.error("Failed to save consent data to localStorage:", error);
}
};
const touch = (meetingId: MeetingId, accepted: boolean): void => {
if (!state.ready) {
console.warn("Attempted to touch consent before context is ready");
return;
}
const newMap = new Map<MeetingId, boolean>(state.consentForMeetings);
newMap.set(meetingId, accepted);
safeWriteToStorage(newMap);
setState({ ready: true, consentForMeetings: newMap });
};
const hasAnswered = (meetingId: MeetingId): boolean => {
if (!state.ready) return false;
return state.consentForMeetings.has(meetingId);
};
const hasAccepted = (meetingId: MeetingId): boolean => {
if (!state.ready) return false;
return state.consentForMeetings.get(meetingId) === true;
};
// Initialize on mount
useEffect(() => {
try {
if (typeof window === "undefined" || !window.localStorage) {
setState({ ready: true, consentForMeetings: new Map() });
return;
}
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!stored) {
setState({ ready: true, consentForMeetings: new Map() });
return;
}
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) {
console.warn("Invalid consent data format in localStorage, resetting");
setState({ ready: true, consentForMeetings: new Map() });
return;
}
const consentForMeetings = new Map<MeetingId, boolean>();
for (const entry of parsed) {
const decoded = decodeEntry(entry);
if (decoded) {
consentForMeetings.set(decoded.meetingId, decoded.accepted);
}
}
setState({ ready: true, consentForMeetings });
} catch (error) {
console.error("Failed to parse consent data from localStorage:", error);
setState({ ready: true, consentForMeetings: new Map() });
}
}, []);
const value: RecordingConsentContextValue = {
state,
touch,
hasAnswered,
hasAccepted,
};
return (
<RecordingConsentContext.Provider value={value}>
{children}
</RecordingConsentContext.Provider>
);
};

4421
www/appv2/src/lib/reflector-api.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
/**
* Sentry initialization — client-side only (replaces @sentry/nextjs).
* Import this file at the very top of main.tsx.
*/
import * as Sentry from "@sentry/react";
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN;
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0,
replaysOnErrorSampleRate: 0.0,
replaysSessionSampleRate: 0.0,
debug: false,
});
}
export { Sentry };

View File

@@ -0,0 +1,492 @@
// type Script = 'Latn' | 'Ethi' | 'Arab' | 'Beng' | 'Cyrl' | 'Taml' | 'Hant' | 'Hans' | 'Grek' | 'Gujr' | 'Hebr'| 'Deva'| 'Armn' | 'Jpan' | 'Knda' | 'Geor';
type LanguageOption = {
value: string | undefined;
name: string;
script?: string;
};
const supportedLanguages: LanguageOption[] = [
{
value: "",
name: "No translation",
},
{
value: "af",
name: "Afrikaans",
script: "Latn",
},
{
value: "am",
name: "Amharic",
script: "Ethi",
},
{
value: "ar",
name: "Modern Standard Arabic",
script: "Arab",
},
{
value: "ary",
name: "Moroccan Arabic",
script: "Arab",
},
{
value: "arz",
name: "Egyptian Arabic",
script: "Arab",
},
{
value: "as",
name: "Assamese",
script: "Beng",
},
{
value: "az",
name: "North Azerbaijani",
script: "Latn",
},
{
value: "be",
name: "Belarusian",
script: "Cyrl",
},
{
value: "bn",
name: "Bengali",
script: "Beng",
},
{
value: "bs",
name: "Bosnian",
script: "Latn",
},
{
value: "bg",
name: "Bulgarian",
script: "Cyrl",
},
{
value: "ca",
name: "Catalan",
script: "Latn",
},
{
value: "ceb",
name: "Cebuano",
script: "Latn",
},
{
value: "cs",
name: "Czech",
script: "Latn",
},
{
value: "ku",
name: "Central Kurdish",
script: "Arab",
},
{
value: "cmn",
name: "Mandarin Chinese",
script: "Hans",
},
{
value: "cy",
name: "Welsh",
script: "Latn",
},
{
value: "da",
name: "Danish",
script: "Latn",
},
{
value: "de",
name: "German",
script: "Latn",
},
{
value: "el",
name: "Greek",
script: "Grek",
},
{
value: "en",
name: "English",
script: "Latn",
},
{
value: "et",
name: "Estonian",
script: "Latn",
},
{
value: "eu",
name: "Basque",
script: "Latn",
},
{
value: "fi",
name: "Finnish",
script: "Latn",
},
{
value: "fr",
name: "French",
script: "Latn",
},
{
value: "gaz",
name: "West Central Oromo",
script: "Latn",
},
{
value: "ga",
name: "Irish",
script: "Latn",
},
{
value: "gl",
name: "Galician",
script: "Latn",
},
{
value: "gu",
name: "Gujarati",
script: "Gujr",
},
{
value: "he",
name: "Hebrew",
script: "Hebr",
},
{
value: "hi",
name: "Hindi",
script: "Deva",
},
{
value: "hr",
name: "Croatian",
script: "Latn",
},
{
value: "hu",
name: "Hungarian",
script: "Latn",
},
{
value: "hy",
name: "Armenian",
script: "Armn",
},
{
value: "ig",
name: "Igbo",
script: "Latn",
},
{
value: "id",
name: "Indonesian",
script: "Latn",
},
{
value: "is",
name: "Icelandic",
script: "Latn",
},
{
value: "it",
name: "Italian",
script: "Latn",
},
{
value: "jv",
name: "Javanese",
script: "Latn",
},
{
value: "ja",
name: "Japanese",
script: "Jpan",
},
{
value: "kn",
name: "Kannada",
script: "Knda",
},
{
value: "ka",
name: "Georgian",
script: "Geor",
},
{
value: "kk",
name: "Kazakh",
script: "Cyrl",
},
{
value: "khk",
name: "Halh Mongolian",
script: "Cyrl",
},
{
value: "km",
name: "Khmer",
script: "Khmr",
},
{
value: "ky",
name: "Kyrgyz",
script: "Cyrl",
},
{
value: "ko",
name: "Korean",
script: "Kore",
},
{
value: "lo",
name: "Lao",
script: "Laoo",
},
{
value: "lt",
name: "Lithuanian",
script: "Latn",
},
{
value: "lg",
name: "Ganda",
script: "Latn",
},
{
value: "luo",
name: "Luo",
script: "Latn",
},
{
value: "lv",
name: "Standard Latvian",
script: "Latn",
},
{
value: "mai",
name: "Maithili",
script: "Deva",
},
{
value: "ml",
name: "Malayalam",
script: "Mlym",
},
{
value: "mr",
name: "Marathi",
script: "Deva",
},
{
value: "mk",
name: "Macedonian",
script: "Cyrl",
},
{
value: "mt",
name: "Maltese",
script: "Latn",
},
{
value: "mni",
name: "Meitei",
script: "Beng",
},
{
value: "my",
name: "Burmese",
script: "Mymr",
},
{
value: "nl",
name: "Dutch",
script: "Latn",
},
{
value: "nn",
name: "Norwegian Nynorsk",
script: "Latn",
},
{
value: "nb",
name: "Norwegian Bokmål",
script: "Latn",
},
{
value: "ne",
name: "Nepali",
script: "Deva",
},
{
value: "ny",
name: "Nyanja",
script: "Latn",
},
{
value: "or",
name: "Odia",
script: "Orya",
},
{
value: "pa",
name: "Punjabi",
script: "Guru",
},
{
value: "pbt",
name: "Southern Pashto",
script: "Arab",
},
{
value: "pes",
name: "Western Persian",
script: "Arab",
},
{
value: "pl",
name: "Polish",
script: "Latn",
},
{
value: "pt",
name: "Portuguese",
script: "Latn",
},
{
value: "ro",
name: "Romanian",
script: "Latn",
},
{
value: "ru",
name: "Russian",
script: "Cyrl",
},
{
value: "sk",
name: "Slovak",
script: "Latn",
},
{
value: "sl",
name: "Slovenian",
script: "Latn",
},
{
value: "sn",
name: "Shona",
script: "Latn",
},
{
value: "sd",
name: "Sindhi",
script: "Arab",
},
{
value: "so",
name: "Somali",
script: "Latn",
},
{
value: "es",
name: "Spanish",
script: "Latn",
},
{
value: "sr",
name: "Serbian",
script: "Cyrl",
},
{
value: "sv",
name: "Swedish",
script: "Latn",
},
{
value: "sw",
name: "Swahili",
script: "Latn",
},
{
value: "ta",
name: "Tamil",
script: "Taml",
},
{
value: "te",
name: "Telugu",
script: "Telu",
},
{
value: "tg",
name: "Tajik",
script: "Cyrl",
},
{
value: "tl",
name: "Tagalog",
script: "Latn",
},
{
value: "th",
name: "Thai",
script: "Thai",
},
{
value: "tr",
name: "Turkish",
script: "Latn",
},
{
value: "uk",
name: "Ukrainian",
script: "Cyrl",
},
{
value: "ur",
name: "Urdu",
script: "Arab",
},
{
value: "uz",
name: "Northern Uzbek",
script: "Latn",
},
{
value: "vi",
name: "Vietnamese",
script: "Latn",
},
{
value: "yo",
name: "Yoruba",
script: "Latn",
},
{
value: "yue",
name: "Cantonese",
script: "Hant",
},
{
value: "ms",
name: "Standard Malay",
script: "Latn",
},
{
value: "zu",
name: "Zulu",
script: "Latn",
},
];
supportedLanguages.push({ value: "NOTRANSLATION", name: "No Translation" });
export { supportedLanguages };

View File

@@ -0,0 +1,25 @@
export const formatDateTime = (d: Date): string => {
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
export const formatStartedAgo = (
startTime: Date,
now: Date = new Date(),
): string => {
const diff = now.getTime() - startTime.getTime();
if (diff <= 0) return "Starting now";
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`;
if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`;
return `Started ${minutes} minutes ago`;
};

View File

@@ -0,0 +1,13 @@
import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils";
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
export type MeetingId = NonEmptyString & { __type: "MeetingId" };
export const assertMeetingId = (s: string): MeetingId => {
const nes = assertExistsAndNonEmptyString(s);
return nes as MeetingId;
};
export type DailyRecordingType = "cloud" | "raw-tracks";

185
www/appv2/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,185 @@
// ─── Utility functions ported from Next.js app/lib/utils.ts ──────────────────
// WCAG contrast ratio
export const getContrastRatio = (
foreground: [number, number, number],
background: [number, number, number],
) => {
const [r1, g1, b1] = foreground;
const [r2, g2, b2] = background;
const lum1 =
0.2126 * Math.pow(r1 / 255, 2.2) +
0.7152 * Math.pow(g1 / 255, 2.2) +
0.0722 * Math.pow(b1 / 255, 2.2);
const lum2 =
0.2126 * Math.pow(r2 / 255, 2.2) +
0.7152 * Math.pow(g2 / 255, 2.2) +
0.0722 * Math.pow(b2 / 255, 2.2);
return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
};
// 🔴 DO NOT USE FOR CRYPTOGRAPHY PURPOSES 🔴
export function murmurhash3_32_gc(key: string, seed: number = 0) {
let remainder, bytes, h1, h1b, c1, c2, k1, i;
remainder = key.length & 3;
bytes = key.length - remainder;
h1 = seed;
c1 = 0xcc9e2d51;
c2 = 0x1b873593;
i = 0;
while (i < bytes) {
k1 =
(key.charCodeAt(i) & 0xff) |
((key.charCodeAt(++i) & 0xff) << 8) |
((key.charCodeAt(++i) & 0xff) << 16) |
((key.charCodeAt(++i) & 0xff) << 24);
++i;
k1 =
((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) &
0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 =
((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) &
0xffffffff;
h1 ^= k1;
h1 = (h1 << 13) | (h1 >>> 19);
h1b =
((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff;
h1 =
(h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16);
}
k1 = 0;
switch (remainder) {
case 3:
k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
// falls through
case 2:
k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
// falls through
case 1:
k1 ^= key.charCodeAt(i) & 0xff;
k1 =
((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) &
0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 =
((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) &
0xffffffff;
h1 ^= k1;
}
h1 ^= key.length;
h1 ^= h1 >>> 16;
h1 =
((h1 & 0xffff) * 0x85ebca6b +
((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) &
0xffffffff;
h1 ^= h1 >>> 13;
h1 =
((h1 & 0xffff) * 0xc2b2ae35 +
((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) &
0xffffffff;
h1 ^= h1 >>> 16;
return h1 >>> 0;
}
// Generates a color guaranteed to have high contrast with the given background
export const generateHighContrastColor = (
name: string,
backgroundColor: [number, number, number],
) => {
let loopNumber = 0;
let minAcceptedContrast = 3.5;
while (loopNumber < 100) {
++loopNumber;
if (loopNumber > 5) minAcceptedContrast -= 0.5;
const hash = murmurhash3_32_gc(name + loopNumber);
let red = (hash & 0xff0000) >> 16;
let green = (hash & 0x00ff00) >> 8;
let blue = hash & 0x0000ff;
let contrast = getContrastRatio([red, green, blue], backgroundColor);
if (contrast > minAcceptedContrast) return `rgb(${red}, ${green}, ${blue})`;
// Try inverted color
red = Math.abs(255 - red);
green = Math.abs(255 - green);
blue = Math.abs(255 - blue);
contrast = getContrastRatio([red, green, blue], backgroundColor);
if (contrast > minAcceptedContrast) return `rgb(${red}, ${green}, ${blue})`;
}
};
export function extractDomain(url: string): string | null {
try {
const parsedUrl = new URL(url);
return parsedUrl.host;
} catch {
return null;
}
}
// ─── Branded Types & Assertions ──────────────────────────────────────────────
export type NonEmptyString = string & { __brand: "NonEmptyString" };
export const parseMaybeNonEmptyString = (
s: string,
trim = true,
): NonEmptyString | null => {
s = trim ? s.trim() : s;
return s.length > 0 ? (s as NonEmptyString) : null;
};
export const parseNonEmptyString = (
s: string,
trim = true,
e?: string,
): NonEmptyString =>
assertExists(
parseMaybeNonEmptyString(s, trim),
"Expected non-empty string" + (e ? `: ${e}` : ""),
);
export const assertExists = <T>(
value: T | null | undefined,
err?: string,
): T => {
if (value === null || value === undefined) {
throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`);
}
return value;
};
export const assertNotExists = <T>(
value: T | null | undefined,
err?: string,
): void => {
if (value !== null && value !== undefined) {
throw new Error(
`Assertion failed: ${err ?? "value is not null or undefined"}`,
);
}
};
export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
err?: string,
): NonEmptyString =>
parseNonEmptyString(
assertExists(value, err || "Expected non-empty string"),
true,
err,
);

View File

@@ -0,0 +1,26 @@
/**
* Whereby client helpers — ported from Next.js app/lib/wherebyClient.ts
*/
import { useEffect, useState } from "react";
import type { components } from "./reflector-api";
export const useWhereby = () => {
const [wherebyLoaded, setWherebyLoaded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true);
})
.catch(console.error.bind(console));
}
}, []);
return wherebyLoaded;
};
export const getWherebyUrl = (
meeting: Pick<components["schemas"]["Meeting"], "room_url" | "host_room_url">,
) =>
// host_room_url possible '' atm
meeting.host_room_url || meeting.room_url;

16
www/appv2/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
// Sentry must init before React
import "./lib/sentry";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,55 @@
import React from "react";
import { Info } from "lucide-react";
export default function AboutPage() {
return (
<div className="min-h-screen bg-surface py-12 px-6">
<div className="max-w-3xl mx-auto bg-surface-low rounded-2xl p-8 md:p-12 shadow-sm border border-outline-variant/20">
<div className="flex items-center gap-3 mb-8">
<Info className="w-8 h-8 text-primary" />
<h1 className="text-3xl font-serif font-bold text-on-surface">About Us</h1>
</div>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<p className="text-lg">
<strong>Reflector</strong> is a transcription and summarization pipeline that transforms audio into knowledge. The output is meeting minutes and topic summaries enabling topic-specific analyses stored in your systems of record. This is accomplished on your infrastructure without 3rd parties keeping your data private, secure, and organized.
</p>
<section className="space-y-4">
<h2 className="text-2xl font-serif font-bold text-on-surface border-b border-outline-variant/10 pb-2">FAQs</h2>
<div className="mt-6">
<h3 className="text-lg font-bold text-on-surface mb-2">1. How does it work?</h3>
<p>Reflector simplifies tasks, turning spoken words into organized information. Just press "record" to start and "stop" to finish. You'll get notes divided by topic, a meeting summary, and the option to download recordings.</p>
</div>
<div className="mt-6">
<h3 className="text-lg font-bold text-on-surface mb-2">2. What makes Reflector different?</h3>
<p>Monadical prioritizes safeguarding your data. Reflector operates exclusively on your infrastructure, ensuring guaranteed security.</p>
</div>
<div className="mt-6">
<h3 className="text-lg font-bold text-on-surface mb-2">3. Are there any industry-specific use cases?</h3>
<p className="mb-2">Absolutely! We have two custom deployments pre-built:</p>
<ul className="list-disc pl-6 space-y-2 text-on-surface-variant">
<li><strong>Reflector Media:</strong> Ideal for meetings, providing real-time notes and topic summaries.</li>
<li><strong>Projector Reflector:</strong> Suited for larger events, offering live topic summaries, translations, and agenda tracking.</li>
</ul>
</div>
<div className="mt-6">
<h3 className="text-lg font-bold text-on-surface mb-2">4. Whos behind Reflector?</h3>
<p>Monadical is a cohesive and effective team that can connect seamlessly into your workflows, and we are ready to integrate Reflectors building blocks into your custom tools. Were committed to building software that outlasts us 🐙.</p>
</div>
</section>
<footer className="pt-8 mt-12 border-t border-outline-variant/10 text-center">
<p className="text-on-surface font-medium">
Contact us at <a href="mailto:hello@monadical.com" className="text-primary hover:text-primary-active underline underline-offset-4 decoration-primary/30">hello@monadical.com</a>
</p>
</footer>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../lib/AuthProvider";
import { ArrowRight, Fingerprint, Sparkles, LogIn } from "lucide-react";
export default function LoginPage() {
const { signIn } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleCredentialsLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const result = await signIn("credentials", { email, password });
setLoading(false);
if (result.ok) {
navigate("/welcome");
} else {
setError(result.error || "Invalid email or password");
}
};
const handleSSOLogin = () => {
signIn("sso");
};
return (
<div className="min-h-screen bg-surface flex flex-col font-sans text-on-surface selection:bg-primary-fixed">
{/* Top Navigation */}
<header className="fixed top-0 w-full z-50 flex justify-between items-center px-6 py-4 bg-surface/85 backdrop-blur-[12px]">
<div className="flex items-center gap-3">
<img
src="https://reflector.monadical.com/reach.svg"
alt="Reflector Logo"
className="w-6 h-6"
/>
<span className="text-2xl font-bold text-on-surface tracking-tight font-serif">
Reflector
</span>
</div>
<div className="flex items-center gap-8">
<nav className="hidden md:flex items-center gap-6">
<a
href="#"
className="text-on-surface-variant font-medium hover:text-primary transition-colors duration-300 text-sm"
>
Collections
</a>
<span className="text-outline-variant/60">·</span>
<a
href="#"
className="text-on-surface-variant font-medium hover:text-primary transition-colors duration-300 text-sm"
>
Exhibitions
</a>
<span className="text-outline-variant/60">·</span>
<a
href="#"
className="text-on-surface-variant font-medium hover:text-primary transition-colors duration-300 text-sm"
>
Journal
</a>
</nav>
<button
onClick={handleSSOLogin}
className="bg-gradient-primary text-white px-[18px] py-[6px] rounded-sm text-sm font-semibold hover:brightness-110 active:brightness-95 transition-all"
>
Log In
</button>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex items-center justify-center px-6 py-24 md:py-0 mt-16 md:mt-0">
<div className="w-full max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 lg:gap-24 items-center">
{/* Left Column: Marketing Copy */}
<div className="md:col-span-7 space-y-8">
<h1 className="text-[2.5rem] text-on-surface leading-[1.1] tracking-tight">
<span className="font-serif">Welcome to </span>
<span className="font-serif italic">Reflector</span>
</h1>
<p className="text-[0.9375rem] text-on-surface-variant max-w-[420px] leading-[1.6]">
Access a curated digital environment designed for intellectual
authority and archival depth. Manage your collections with the
precision of a modern curator.
</p>
<div>
<button className="flex items-center gap-2 text-primary font-semibold text-sm hover:underline underline-offset-4 transition-all group bg-transparent border-none p-0">
Learn more
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</div>
<div className="mt-8 pt-8 space-y-4 max-w-[420px]">
<p className="text-[0.875rem] text-on-surface-variant font-serif italic leading-relaxed">
"The Digital Curator prioritizes warmth, intentionality, and
authority in every interaction."
</p>
<a
href="#"
className="inline-block text-[0.6875rem] uppercase tracking-widest font-semibold text-on-surface-variant hover:text-primary transition-colors"
>
Privacy policy
</a>
</div>
</div>
{/* Right Column: Login Card */}
<div className="md:col-span-5 relative flex justify-center md:justify-end">
<div className="w-full max-w-md bg-surface-highest rounded-md p-8 shadow-card flex flex-col items-center text-center relative z-10">
<div className="text-primary mb-6">
<Fingerprint className="w-6 h-6" strokeWidth={1.5} />
</div>
<h2 className="font-serif text-[1.25rem] font-semibold text-on-surface mb-3">
Secure Access
</h2>
<p className="text-sm text-on-surface-variant max-w-[240px] mx-auto mb-6 leading-relaxed">
Enter the archive to view your curated workspace and historical
logs.
</p>
{/* Error Message */}
{error && (
<div className="w-full mb-4 px-3 py-2 bg-red-50 border border-red-200 rounded-sm text-sm text-red-700">
{error}
</div>
)}
{/* Credentials Form */}
<form
onSubmit={handleCredentialsLogin}
className="w-full space-y-3 mb-4"
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2.5 bg-surface-mid border border-outline-variant/30 rounded-sm text-sm text-on-surface placeholder:text-muted focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2.5 bg-surface-mid border border-outline-variant/30 rounded-sm text-sm text-on-surface placeholder:text-muted focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors"
/>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-primary text-white font-semibold rounded-sm hover:brightness-110 active:brightness-95 transition-all text-base disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<LogIn className="w-4 h-4" />
Log In
</>
)}
</button>
</form>
{/* Divider */}
<div className="w-full flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-outline-variant/20" />
<span className="text-[0.6875rem] text-muted uppercase tracking-wider font-medium">
or
</span>
<div className="flex-1 h-px bg-outline-variant/20" />
</div>
{/* SSO Button */}
<button
onClick={handleSSOLogin}
className="w-full py-2.5 border border-outline-variant/30 text-on-surface-variant font-medium rounded-sm hover:bg-surface-mid hover:border-primary/30 transition-all text-sm"
>
Continue with SSO
</button>
<p className="mt-6 text-[0.6875rem] text-muted uppercase tracking-widest font-medium">
Authorized Personnel Only
</p>
</div>
{/* Floating Editorial Detail */}
<div className="absolute -bottom-4 -left-4 md:-left-8 bg-surface-mid px-2.5 py-1 rounded-sm shadow-sm flex items-center gap-1.5 z-20 border border-outline-variant/20">
<Sparkles className="w-3 h-3 text-on-surface-variant" />
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-wider">
Curated Experience Engine v6.9
</span>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="mt-auto bg-surface-low py-8 px-8 flex flex-col md:flex-row justify-between items-center gap-4 border-t border-outline-variant/20">
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-widest">
© 2024 Reflector Archive
</span>
<div className="flex items-center gap-6">
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Learn more</a>
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Privacy policy</a>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { ShieldAlert } from "lucide-react";
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-surface py-12 px-6">
<div className="max-w-3xl mx-auto bg-surface-low rounded-2xl p-8 md:p-12 shadow-sm border border-outline-variant/20">
<div className="flex items-center gap-3 mb-2">
<ShieldAlert className="w-8 h-8 text-primary" />
<h1 className="text-3xl font-serif font-bold text-on-surface">Privacy Policy</h1>
</div>
<p className="text-sm font-medium text-muted mb-8 italic">Last updated on September 22, 2023</p>
<div className="space-y-6 text-on-surface-variant leading-relaxed">
<ul className="space-y-6">
<li className="flex flex-col">
<strong className="text-lg text-on-surface mb-1">Recording Consent</strong>
<p>By using Reflector, you grant us permission to record your interactions for the purpose of showcasing Reflector's capabilities during the All In AI conference.</p>
</li>
<li className="flex flex-col">
<strong className="text-lg text-on-surface mb-1">Data Access</strong>
<p>You will have convenient access to your recorded sessions and transcriptions via a unique URL, which remains active for a period of seven days. After this time, your recordings and transcripts will be permanently deleted.</p>
</li>
<li className="flex flex-col">
<strong className="text-lg text-on-surface mb-1">Data Confidentiality</strong>
<p>Rest assured that none of your audio data will be shared with third parties.</p>
</li>
</ul>
<footer className="pt-8 mt-12 border-t border-outline-variant/10 text-center">
<p className="text-on-surface font-medium">
Questions or Concerns: If you have any questions or concerns regarding your data, please feel free to reach out to us at{" "}
<a href="mailto:reflector@monadical.com" className="text-primary hover:text-primary-active underline underline-offset-4 decoration-primary/30">
reflector@monadical.com
</a>
</p>
</footer>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useRoomGetByName, useRoomsCreateMeeting, useRoomGetMeeting } from '../lib/apiHooks';
import { useAuth } from '../lib/AuthProvider';
import { useError } from '../lib/errorContext';
import { printApiError } from '../api/_error';
import { assertMeetingId } from '../lib/types';
import MeetingSelection from '../components/rooms/MeetingSelection';
import useRoomDefaultMeeting from '../hooks/rooms/useRoomDefaultMeeting';
import WherebyRoom from '../components/rooms/WherebyRoom';
import DailyRoom from '../components/rooms/DailyRoom';
function LoadingSpinner() {
return (
<div className="flex justify-center items-center h-screen bg-surface">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
);
}
export default function RoomMeetingPage() {
const { roomName, meetingId: pageMeetingId } = useParams<{ roomName: string; meetingId?: string }>();
const navigate = useNavigate();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === 'authenticated';
const { setError } = useError();
if (!roomName) {
return <div className="p-8 text-red-500">Missing Room Parameter</div>;
}
const roomQuery = useRoomGetByName(roomName);
const createMeetingMutation = useRoomsCreateMeeting();
const room = roomQuery.data;
const defaultMeeting = useRoomDefaultMeeting(room && !room.ics_enabled && !pageMeetingId ? roomName : null);
const explicitMeeting = useRoomGetMeeting(
roomName,
pageMeetingId ? assertMeetingId(pageMeetingId) : null
);
const meeting = explicitMeeting.data || defaultMeeting.response;
const isLoading =
status === 'loading' ||
roomQuery.isLoading ||
defaultMeeting?.loading ||
explicitMeeting.isLoading ||
createMeetingMutation.isPending;
const errors = [
explicitMeeting.error,
defaultMeeting.error,
roomQuery.error,
createMeetingMutation.error,
].filter(Boolean);
const isOwner = auth.status === 'authenticated' && room ? auth.user.id === room.user_id : false;
const handleMeetingSelect = (selectedMeeting: any) => {
navigate(`/rooms/${roomName}/${selectedMeeting.id}`);
};
const handleCreateUnscheduled = async () => {
try {
const newMeeting = await createMeetingMutation.mutateAsync({
params: { path: { room_name: roomName } },
body: { allow_duplicated: room ? room.ics_enabled : false },
});
handleMeetingSelect(newMeeting);
} catch (err) {
console.error('Failed to create meeting:', err);
}
};
if (isLoading) {
return <LoadingSpinner />;
}
if (!room && !isLoading) {
return (
<div className="flex justify-center items-center h-screen bg-surface">
<p className="text-xl font-serif text-muted">Room not found or unauthorized.</p>
</div>
);
}
if (room?.ics_enabled && !pageMeetingId) {
return (
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
isSharedRoom={room?.is_shared || false}
authLoading={['loading', 'refreshing'].includes(auth.status)}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
isCreatingMeeting={createMeetingMutation.isPending}
/>
);
}
if (errors.length > 0) {
return (
<div className="flex flex-col justify-center items-center h-screen bg-surface gap-2">
{errors.map((error, i) => (
<p key={i} className="text-red-500 font-semibold bg-red-50 p-4 rounded-md border border-red-200">
{printApiError(error)}
</p>
))}
</div>
);
}
if (!meeting) {
return <LoadingSpinner />;
}
const platform = meeting.platform;
if (!platform) {
return (
<div className="flex justify-center items-center h-screen bg-surface">
<p className="text-lg font-medium text-muted">Meeting platform not configured properly.</p>
</div>
);
}
switch (platform) {
case 'daily':
return <DailyRoom meeting={meeting} room={room} />;
case 'whereby':
return <WherebyRoom meeting={meeting} room={room} />;
default: {
return (
<div className="flex justify-center items-center h-screen bg-surface">
<p className="text-lg text-red-500">Unknown platform: {platform}</p>
</div>
);
}
}
}

View File

@@ -0,0 +1,351 @@
import React, { useState } from 'react';
import { useRoomsList, useRoomDelete } from '../lib/apiHooks';
import { useAuth } from '../lib/AuthProvider';
import { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
import { AddRoomModal } from '../components/rooms/AddRoomModal';
import { useNavigate } from 'react-router-dom';
import {
PlusCircle,
Compass,
FolderOpen,
Link as LinkIcon,
MoreVertical,
Wrench,
CheckCircle2,
Edit3,
Trash2,
Calendar,
Clock,
RefreshCw
} from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useRoomActiveMeetings, useRoomUpcomingMeetings, useRoomIcsSync } from '../lib/apiHooks';
const MEETING_DEFAULT_TIME_MINUTES = 15;
const getRoomModeDisplay = (mode: string): string => {
switch (mode) {
case "normal": return "2-4 people";
case "group": return "2-200 people";
default: return mode;
}
};
const getRecordingDisplay = (type: string, trigger: string): string => {
if (type === "none") return "-";
if (type === "local") return "Local";
if (type === "cloud") {
switch (trigger) {
case "none": return "Cloud (None)";
case "prompt": return "Cloud (Prompt)";
case "automatic-2nd-participant": return "Cloud (Auto)";
default: return `Cloud (${trigger})`;
}
}
return type;
};
const getZulipDisplay = (autoPost: boolean, stream: string, topic: string): string => {
if (!autoPost) return "-";
if (stream && topic) return `${stream} > ${topic}`;
if (stream) return stream;
return "Enabled";
};
function MeetingStatus({ roomName }: { roomName: string }) {
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const activeMeetings = activeMeetingsQuery.data || [];
const upcomingMeetings = upcomingMeetingsQuery.data || [];
if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
return <div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />;
}
if (activeMeetings.length > 0) {
const meeting = activeMeetings[0];
const title = String(meeting.calendar_metadata?.['title'] || "Active Meeting");
return (
<div className="flex flex-col gap-1 items-start">
<span className="font-sans text-[0.75rem] text-on-surface font-semibold leading-none">{title}</span>
<span className="font-sans text-[0.6875rem] text-muted leading-none">{meeting.num_clients} participants</span>
</div>
);
}
if (upcomingMeetings.length > 0) {
const event = upcomingMeetings[0];
const startTime = new Date(event.start_time);
const now = new Date();
const diffMinutes = Math.floor((startTime.getTime() - now.getTime()) / 60000);
return (
<div className="flex flex-col gap-1 items-start">
<span className="inline-block bg-[#D2E7D9] text-[#1D4A2F] dark:bg-[#1D4A2F] dark:text-[#D2E7D9] px-2 py-0.5 rounded font-sans text-[0.625rem] font-bold uppercase tracking-wider">
{diffMinutes < MEETING_DEFAULT_TIME_MINUTES ? `In ${diffMinutes}m` : "Upcoming"}
</span>
<span className="font-sans text-[0.75rem] text-on-surface font-semibold leading-none mt-0.5">
{event.title || "Scheduled Meeting"}
</span>
<span className="font-sans text-[0.6875rem] text-muted leading-none">
{startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" })}
</span>
</div>
);
}
return <span className="font-sans text-[0.75rem] text-muted italic">No meetings</span>;
}
export default function RoomsPage() {
const queryClient = useQueryClient();
const { data: roomsData, isLoading, isError } = useRoomsList(1);
const syncMutation = useRoomIcsSync();
const deleteRoomMutation = useRoomDelete();
const [isAddRoomModalOpen, setIsAddRoomModalOpen] = useState(false);
const [editRoomId, setEditRoomId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'my' | 'shared'>('my');
const [copiedRoom, setCopiedRoom] = useState<string | null>(null);
const [syncingRooms, setSyncingRooms] = useState<Set<string>>(new Set());
const navigate = useNavigate();
const rooms = roomsData?.items ?? [];
const filteredRooms = rooms.filter(r => (activeTab === 'my') ? !r.is_shared : r.is_shared);
const handleCopyLink = (roomName: string, e: React.MouseEvent) => {
e.stopPropagation();
const url = `${window.location.origin}/rooms/${roomName}`;
navigator.clipboard.writeText(url).then(() => {
setCopiedRoom(roomName);
setTimeout(() => setCopiedRoom(null), 2000);
});
};
const handleForceSync = async (roomName: string, e: React.MouseEvent) => {
e.stopPropagation();
setSyncingRooms((prev) => new Set(prev).add(roomName));
try {
await syncMutation.mutateAsync({
params: { path: { room_name: roomName } },
});
} catch (err) {
console.error("Failed to sync calendar:", err);
} finally {
setSyncingRooms((prev) => {
const next = new Set(prev);
next.delete(roomName);
return next;
});
}
};
const openAddModal = () => {
setEditRoomId(null);
setIsAddRoomModalOpen(true);
};
const openEditModal = (roomId: string, e: React.MouseEvent) => {
e.stopPropagation();
setEditRoomId(roomId);
setIsAddRoomModalOpen(true);
};
const handleDelete = (roomId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (window.confirm("Are you sure you want to delete this room? This action cannot be reversed.")) {
deleteRoomMutation.mutate({
params: { path: { room_id: roomId as any } }
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rooms'] });
}
});
}
};
return (
<div className="flex-1 bg-surface flex flex-col font-sans text-on-surface selection:bg-primary-fixed">
<main className="flex-1 p-8 md:p-12 w-full space-y-10">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="font-serif text-[1.75rem] font-bold text-on-surface">Rooms</h1>
<Button
variant="primary"
className="flex items-center gap-2 self-start sm:self-auto shadow-[0_0_15px_rgba(235,108,67,0.2)] hover:shadow-[0_0_20px_rgba(235,108,67,0.4)] transition-all"
onClick={openAddModal}
>
<PlusCircle className="w-4 h-4" />
Add Room
</Button>
</div>
{/* Tabs */}
<div className="flex items-center gap-2 border-b border-surface-high pb-4">
<button
onClick={() => setActiveTab('my')}
className={`px-4 py-1.5 rounded-full font-sans text-sm font-semibold transition-colors ${
activeTab === 'my'
? 'bg-primary text-white shadow-sm'
: 'text-muted hover:bg-surface-high hover:text-on-surface-variant'
}`}
>
My Rooms
</button>
<button
onClick={() => setActiveTab('shared')}
className={`px-4 py-1.5 rounded-full font-sans text-sm font-semibold transition-colors ${
activeTab === 'shared'
? 'bg-primary text-white shadow-sm'
: 'text-muted hover:bg-surface-high hover:text-on-surface-variant'
}`}
>
Shared Rooms
</button>
</div>
{/* Rooms Table / Empty State */}
<div className="bg-surface-highest rounded-2xl shadow-card overflow-hidden border border-outline-variant/10">
{isLoading ? (
<div className="p-20 flex flex-col items-center justify-center">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin mb-4" />
<p className="font-serif italic text-sm text-muted">Retrieving rooms...</p>
</div>
) : isError ? (
<div className="p-20 flex flex-col items-center justify-center text-center">
<FolderOpen className="w-10 h-10 text-red-300 mb-4" strokeWidth={1.5} />
<p className="text-sm font-serif italic text-red-600">Archive connection failed. Please try again.</p>
</div>
) : filteredRooms.length === 0 ? (
<div className="p-20 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-full bg-surface-high flex items-center justify-center mb-6">
<FolderOpen className="w-8 h-8 text-on-surface-variant opacity-50" strokeWidth={1.5} />
</div>
<p className="font-serif italic text-on-surface-variant text-lg mb-6 max-w-sm">
{activeTab === 'my' ? "You haven't curated any rooms yet. Begin a new archival context." : "No shared rooms available in your workspace."}
</p>
{activeTab === 'my' && (
<Button variant="secondary" className="flex items-center gap-2" onClick={openAddModal}>
Start Curating
</Button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-outline-variant/10 bg-surface-high/30">
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted">Room Name</th>
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted">Current Meeting</th>
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted">Zulip</th>
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted">Room Size</th>
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted">Recording</th>
<th className="px-6 py-4 font-sans text-[0.6875rem] font-bold uppercase tracking-[0.10em] text-muted text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/5">
{filteredRooms.map((room) => (
<tr key={room.id} className="group hover:bg-surface-low transition-colors duration-200">
<td className="px-6 py-4 align-middle">
<div className="flex flex-col">
<span
onClick={() => navigate(`/rooms/${room.name}`)}
className="font-serif text-[1rem] font-bold text-on-surface hover:text-primary transition-colors cursor-pointer"
>
{room.name}
</span>
</div>
</td>
<td className="px-6 py-4 align-middle">
<MeetingStatus roomName={room.name} />
</td>
<td className="px-6 py-4 align-middle">
<span className="font-sans text-[0.8125rem] text-on-surface-variant">
{getZulipDisplay(room.zulip_auto_post, room.zulip_stream || '', room.zulip_topic || '')}
</span>
</td>
<td className="px-6 py-4 align-middle">
<span className="font-sans text-[0.8125rem] text-on-surface-variant">
{getRoomModeDisplay(room.room_mode || '')}
</span>
</td>
<td className="px-6 py-4 align-middle">
<span className="font-sans text-[0.8125rem] text-on-surface-variant">
{getRecordingDisplay(room.recording_type || '', room.recording_trigger || '')}
</span>
</td>
<td className="px-6 py-4 align-middle text-right">
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{room.ics_enabled && (
<button
onClick={(e) => handleForceSync(room.name, e)}
disabled={syncingRooms.has(room.name)}
title="Force sync calendar"
className="p-2 text-muted hover:text-primary hover:bg-primary/5 rounded-[6px] transition-colors relative"
>
{syncingRooms.has(room.name) ? <div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</button>
)}
<button
onClick={(e) => handleCopyLink(room.name, e)}
title="Copy room link"
className="p-2 text-muted hover:text-primary hover:bg-primary/5 rounded-[6px] transition-colors relative"
>
{copiedRoom === room.name ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <LinkIcon className="w-4 h-4" />}
</button>
<button
onClick={(e) => openEditModal(room.id, e)}
title="Edit room configuration"
className="p-2 text-muted hover:text-secondary hover:bg-secondary/5 rounded-[6px] transition-colors"
>
<Edit3 className="w-4 h-4" />
</button>
{!room.is_shared && (
<button
onClick={(e) => handleDelete(room.id, e)}
title="Permanently discard room"
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/5 rounded-[6px] transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
{/* Footer */}
<footer className="mt-auto bg-surface-low py-8 px-8 flex flex-col md:flex-row justify-between items-center gap-4 border-t border-outline-variant/20">
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-widest">
© 2024 Reflector Archive
</span>
<div className="flex items-center gap-6">
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Learn more</a>
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Privacy policy</a>
</div>
</footer>
<AddRoomModal
isOpen={isAddRoomModalOpen}
onClose={() => setIsAddRoomModalOpen(false)}
editRoomId={editRoomId}
/>
</div>
);
}

View File

@@ -0,0 +1,258 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useAuth } from '../lib/AuthProvider';
import { useApiKeysList, useApiKeyCreate, useApiKeyRevoke } from '../lib/apiHooks';
import { Button } from '../components/ui/Button';
import {
Bell,
KeyRound,
ShieldCheck,
Code2,
ArrowRight,
Plus
} from 'lucide-react';
interface ApiKeyForm {
keyName: string;
}
interface NewKeyData {
name: string;
key: string;
}
export default function SettingsPage() {
const auth = useAuth();
const user = auth.status === 'authenticated' ? auth.user : null;
const { data: keysData } = useApiKeysList();
const createKeyMutation = useApiKeyCreate();
const revokeKeyMutation = useApiKeyRevoke();
const apiKeys = keysData || [];
const [isCreating, setIsCreating] = useState(false);
const [newKey, setNewKey] = useState<NewKeyData | null>(null);
const { register, handleSubmit, reset, formState: { errors } } = useForm<ApiKeyForm>();
const onSubmit = (data: ApiKeyForm) => {
createKeyMutation.mutate({ body: { name: data.keyName } }, {
onSuccess: (response) => {
setNewKey({ name: response.name || data.keyName, key: response.key });
reset();
setIsCreating(false);
}
});
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
alert('Key copied to clipboard!');
} catch (err) {
console.error('Failed to copy text: ', err);
}
};
return (
<div className="flex-1 bg-surface flex flex-col font-sans text-on-surface selection:bg-primary-fixed">
{/* Content Area */}
<main className="flex-1 w-full max-w-[860px] mx-auto px-[24px] py-[40px]">
{/* Page Header */}
<div className="mb-8">
<h1 className="font-serif text-[2rem] font-bold text-[#1b1c14] leading-tight mb-1">API Keys</h1>
<p className="font-sans text-[0.9375rem] text-[#a09a8e]">
Manage your API keys to authenticate with the Editorial Archive API. Keep these keys secure and never share them publicly.
</p>
</div>
{/* Create New API Key Card */}
<div className="bg-[#FFFFFF] rounded-[12px] p-[20px] md:px-[24px] shadow-[0_4px_24px_rgba(27,28,20,0.06)] mb-8">
{!isCreating ? (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="font-serif text-[1.25rem] font-bold text-[#1b1c14] mb-1">Create New API Key</h2>
<p className="font-sans text-[0.9375rem] text-[#5a5850]">
Generate a new secret token to access our archival endpoints.
</p>
</div>
<button
onClick={() => { setIsCreating(true); setNewKey(null); }}
className="shrink-0 bg-gradient-to-br from-[#a63500] to-[#c84c1a] text-white font-sans font-semibold text-[0.9375rem] px-[18px] py-[8px] rounded-[6px] hover:opacity-90 transition-opacity flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Create API Key
</button>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col md:flex-row md:items-start gap-4">
<div className="flex-1">
<label className="block font-sans text-[0.8125rem] font-bold text-[#1b1c14] mb-1.5 uppercase tracking-wider">Key Name</label>
<input
type="text"
{...register('keyName', { required: true, minLength: 3 })}
placeholder="e.g., Production Server"
className="w-full bg-[#f6f4e7] border border-outline-variant/20 rounded-[6px] px-3 py-2 text-[0.9375rem] text-[#1b1c14] focus:outline-none focus:ring-1 focus:ring-[#DC5A28] focus:border-[#DC5A28] transition-all"
autoFocus
/>
{errors.keyName && <p className="text-[#ba1a1a] text-xs mt-1.5">Name is required (min 3 characters).</p>}
</div>
<div className="flex items-center gap-2 md:mt-[26px]">
<Button type="button" variant="secondary" onClick={() => { setIsCreating(false); reset(); }}>Cancel</Button>
<button
type="submit"
className="bg-gradient-to-br from-[#a63500] to-[#c84c1a] text-white font-sans font-semibold text-[0.9375rem] px-[18px] py-[8px] rounded-[6px] hover:opacity-90 transition-opacity"
>
Generate Key
</button>
</div>
</form>
)}
</div>
{newKey && (
<div className="mt-8 bg-surface-high border border-[#DC5A28]/30 rounded-[12px] p-[24px] shadow-sm animate-in fade-in slide-in-from-top-4 duration-300">
<div className="flex items-start gap-4">
<div className="p-2 bg-[#DC5A28]/10 rounded-full mt-1">
<KeyRound className="w-5 h-5 text-[#DC5A28]" />
</div>
<div className="flex-1">
<h3 className="font-serif text-[1.125rem] font-bold text-[#1b1c14] mb-1">
API Key Created: {newKey.name}
</h3>
<p className="font-sans text-[0.9375rem] text-[#DC5A28] font-medium mb-4">
Make sure to copy your personal access token now. You won't be able to see it again!
</p>
<div className="flex items-center gap-2">
<div className="bg-[#f0eee1] px-4 py-3 rounded-[6px] flex-1 font-mono text-[0.9375rem] text-[#1b1c14] overflow-x-auto border border-outline-variant/10">
{newKey.key}
</div>
<button
onClick={() => copyToClipboard(newKey.key)}
className="shrink-0 bg-[#E5E2D9] text-[#1b1c14] font-sans font-semibold text-[0.875rem] px-[16px] py-[10px] rounded-[6px] hover:bg-[#D5D2C9] transition-colors"
>
Copy Key
</button>
</div>
</div>
<button
onClick={() => setNewKey(null)}
className="text-muted hover:text-primary p-1"
aria-label="Close"
>
</button>
</div>
</div>
)}
{/* Your API Keys Section */}
<div className="mt-[32px]">
<h2 className="font-serif text-[1.25rem] font-bold text-[#1b1c14] mb-4">Your API Keys</h2>
{apiKeys.length === 0 ? (
/* Empty State */
<div className="bg-[#f6f4e7] rounded-[12px] p-[48px] px-[24px] flex flex-col items-center justify-center text-center">
<KeyRound className="w-10 h-10 text-[#C8C8BE] mb-4" />
<p className="font-serif italic text-[1rem] text-[#a09a8e] mb-2">No API keys yet.</p>
<p className="font-sans text-[0.9375rem] text-[#a09a8e] max-w-md">
You haven't generated any keys yet. Create one above to start curating your archive via API.
</p>
</div>
) : (
/* Table View */
<div className="w-full overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr>
<th className="font-sans text-[0.75rem] font-bold text-[#a09a8e] uppercase tracking-wider pb-3 px-4 font-normal">Name</th>
<th className="font-sans text-[0.75rem] font-bold text-[#a09a8e] uppercase tracking-wider pb-3 px-4 font-normal">Id</th>
<th className="font-sans text-[0.75rem] font-bold text-[#a09a8e] uppercase tracking-wider pb-3 px-4 font-normal">Created</th>
<th className="font-sans text-[0.75rem] font-bold text-[#a09a8e] uppercase tracking-wider pb-3 px-4 font-normal text-right">Actions</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="group hover:bg-[#f6f4e7] transition-colors border-t border-outline-variant/10">
<td className="py-4 px-4 font-sans text-[0.9375rem] font-semibold text-[#1b1c14]">{key.name}</td>
<td className="py-4 px-4">
<span className="font-mono text-[0.8125rem] text-[#5a5850] bg-[#f0eee1] rounded-[6px] px-[8px] py-[2px]">
...{key.id.slice(-6)}
</span>
</td>
<td className="py-4 px-4 font-sans text-[0.9375rem] text-[#5a5850]">{new Date(key.created_at).toLocaleDateString()}</td>
<td className="py-4 px-4 text-right">
<button
onClick={() => {
if (confirm('Are you sure you want to revoke this key?')) {
revokeKeyMutation.mutate({ params: { path: { key_id: key.id } } });
}
}}
className="font-sans text-[0.9375rem] font-medium text-[#DC5A28] hover:underline transition-all"
>
Revoke
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Bottom Info Cards Row */}
<div className="mt-[32px] grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Card 1 — Security Best Practices */}
<div className="bg-[#FFFFFF] rounded-[12px] p-[24px] shadow-[0_4px_24px_rgba(27,28,20,0.04)] border border-outline-variant/10">
<ShieldCheck className="w-6 h-6 text-[#DC5A28] mb-4" />
<h3 className="font-serif text-[1.25rem] font-bold text-[#1b1c14] mb-3">Security Best Practices</h3>
<ul className="space-y-2 font-sans text-[0.9375rem] text-[#5a5850]">
<li className="flex gap-2">
<span className="text-[#DC5A28] mt-0.5"></span>
<span>Never commit your API keys to version control systems like GitHub.</span>
</li>
<li className="flex gap-2">
<span className="text-[#DC5A28] mt-0.5"></span>
<span>Rotate your keys every 90 days to minimize risk of exposure.</span>
</li>
<li className="flex gap-2">
<span className="text-[#DC5A28] mt-0.5"></span>
<span>Use environment variables to store your keys in production.</span>
</li>
</ul>
</div>
{/* Card 2 — API Documentation */}
<div className="bg-[#FFFFFF] rounded-[12px] p-[24px] shadow-[0_4px_24px_rgba(27,28,20,0.04)] border border-outline-variant/10 flex flex-col">
<Code2 className="w-6 h-6 text-[#DC5A28] mb-4" />
<h3 className="font-serif text-[1.25rem] font-bold text-[#1b1c14] mb-3">API Documentation</h3>
<p className="font-sans text-[0.9375rem] text-[#5a5850] mb-6 flex-1">
Learn how to integrate the Editorial Archive into your workflow with our comprehensive guides.
</p>
<a
href="#"
className="inline-flex items-center gap-1 font-sans text-[0.9375rem] font-semibold text-[#DC5A28] hover:text-[#a63500] transition-colors"
>
View Documentation <ArrowRight className="w-4 h-4" />
</a>
</div>
</div>
</main>
{/* Footer */}
<footer className="mt-auto bg-surface-low py-8 px-8 flex flex-col md:flex-row justify-between items-center gap-4 border-t border-outline-variant/20">
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-widest">
© 2024 Reflector Archive
</span>
<div className="flex items-center gap-6">
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Learn more</a>
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Privacy policy</a>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,554 @@
import React, { useRef, useState, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { usePlayerStore } from '../stores/usePlayerStore';
import {
Play,
Pause,
Search,
ChevronDown,
ChevronRight,
Edit3,
Share2,
Download,
Copy,
ChevronLeft,
VolumeX,
} from 'lucide-react';
import { useTranscriptGet, useTranscriptTopicsWithWords, useTranscriptWaveform } from '../lib/apiHooks';
import { useAuth } from '../lib/AuthProvider';
import { UploadView } from '../components/transcripts/UploadView';
import { RecordView } from '../components/transcripts/RecordView';
import { ProcessingView } from '../components/transcripts/ProcessingView';
import { CorrectionEditor } from '../components/transcripts/correction/CorrectionEditor';
// Utility component to handle routing logic automatically based on fetch state
export default function SingleTranscriptionPage() {
const { id } = useParams<{ id: string }>();
const { data: transcript, isLoading, error } = useTranscriptGet(id as any);
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center min-h-[500px] w-full max-w-2xl mx-auto px-6">
<div className="text-center text-on-surface-variant font-medium">Loading Transcript Data...</div>
</div>
);
}
if (error || !transcript) {
return (
<div className="flex-1 flex items-center justify-center min-h-[500px] w-full max-w-2xl mx-auto px-6">
<div className="text-center text-red-500 font-medium">Error loading transcript. It may not exist.</div>
</div>
);
}
const status = transcript.status;
// View routing based on status
if (status === 'processing' || status === 'uploaded') {
return <ProcessingView />;
}
if (status === 'recording') {
return <RecordView transcriptId={id as string} />;
}
if (status === 'idle') {
if (transcript.source_kind === 'file') {
return <UploadView transcriptId={id as string} />;
} else {
return <RecordView transcriptId={id as string} />;
}
}
// If status is 'ended', we render the actual document
return <TranscriptViewer transcript={transcript} id={id as string} />;
}
// Extract the Viewer UI Core logic for the finalized state
function TranscriptViewer({ transcript, id }: { transcript: any; id: string }) {
const navigate = useNavigate();
const auth = useAuth();
const accessToken = auth.status === 'authenticated' ? auth.accessToken : null;
const { isPlaying, setPlaying, currentTime, setCurrentTime } = usePlayerStore();
const audioDeleted = transcript.audio_deleted === true;
const { data: topicsData, isLoading: topicsLoading } = useTranscriptTopicsWithWords(id as any);
// Skip waveform fetch when audio is deleted — endpoint returns 404 and there's nothing to display
const { data: waveformData } = useTranscriptWaveform(audioDeleted ? null : id as any);
const audioRef = useRef<HTMLAudioElement>(null);
const [expandedChapters, setExpandedChapters] = useState<Record<string, boolean>>({});
const [isCorrectionMode, setIsCorrectionMode] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const displayTitle = transcript.title || 'Untitled Meeting';
// API returns duration in milliseconds; audio currentTime is in seconds
const duration = transcript.duration ? transcript.duration / 1000 : 0;
const progressPercent = duration ? (currentTime / duration) * 100 : 0;
const rawWaveform: number[] = (waveformData?.data ?? []) as number[];
// Downsample to ~200 bars then normalize so the tallest bar always fills the container
const sampledWaveform = (() => {
if (!rawWaveform.length) return [];
const targetBars = 200;
const step = Math.max(1, Math.floor(rawWaveform.length / targetBars));
const bars: number[] = [];
for (let i = 0; i < rawWaveform.length && bars.length < targetBars; i += step) {
bars.push(rawWaveform[i]);
}
const maxAmp = Math.max(...bars, 0.001);
return bars.map(v => v / maxAmp);
})();
const [hoveredBar, setHoveredBar] = useState<{ index: number; x: number; y: number } | null>(null);
// Flat sorted segment list for O(n) speaker lookup at any timestamp
const allSegments = useMemo(() => {
if (!topicsData) return [];
return (topicsData as any[])
.flatMap((topic: any) => topic.segments ?? [])
.sort((a: any, b: any) => a.start - b.start);
}, [topicsData]);
// Filter topics/segments by search query; auto-expand topics with hits
const q = searchQuery.trim().toLowerCase();
const filteredTopics = useMemo(() => {
if (!topicsData) return [];
if (!q) return topicsData as any[];
return (topicsData as any[])
.map((topic: any) => {
const titleMatch = topic.title?.toLowerCase().includes(q);
const matchingSegments = (topic.segments ?? []).filter((s: any) =>
s.text?.toLowerCase().includes(q)
);
if (!titleMatch && matchingSegments.length === 0) return null;
return { ...topic, _matchingSegments: matchingSegments };
})
.filter(Boolean);
}, [topicsData, q]);
const totalMatches = useMemo(() =>
filteredTopics.reduce((acc: number, t: any) => acc + (t._matchingSegments?.length ?? 0), 0),
[filteredTopics]
);
// Highlight matching text within a string
const highlight = (text: string) => {
if (!q) return <>{text}</>;
const idx = text.toLowerCase().indexOf(q);
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-primary/20 text-on-surface rounded px-0.5">{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
);
};
const getSegmentAtTime = (timeSeconds: number) => {
let result: any = null;
for (const seg of allSegments) {
if (seg.start <= timeSeconds) result = seg;
else break;
}
return result;
};
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setPlaying(!isPlaying);
};
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
const jumpToTime = (timeInSeconds: number) => {
if (audioRef.current) {
audioRef.current.currentTime = timeInSeconds;
setCurrentTime(timeInSeconds);
if (!isPlaying) {
audioRef.current.play();
setPlaying(true);
}
}
// Expand the topic that contains this timestamp (if collapsed) and scroll to the segment
if (topicsData && (topicsData as any[]).length > 0) {
const topics = topicsData as any[];
let containingTopic: any = null;
for (const t of topics) {
if (t.timestamp <= timeInSeconds) containingTopic = t;
else break;
}
if (containingTopic) {
setExpandedChapters(prev => ({ ...prev, [containingTopic.id]: true }));
setTimeout(() => {
const segments: any[] = containingTopic.segments ?? [];
let activeSeg: any = null;
for (const s of segments) {
if (s.start <= timeInSeconds) activeSeg = s;
else break;
}
if (activeSeg) {
document.getElementById(`line-${activeSeg.start}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 80);
}
}
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickPos = (e.clientX - rect.left) / rect.width;
jumpToTime(clickPos * duration);
};
const toggleChapter = (chapterId: string) => {
setExpandedChapters(prev => ({
...prev,
[chapterId]: prev[chapterId] !== false ? false : true
}));
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="flex-1 min-h-0 bg-surface flex flex-col font-sans text-on-surface selection:bg-primary/20 overflow-hidden">
{/* Native audio element — hidden, only mounted when audio is available */}
{!audioDeleted && (
<audio
ref={audioRef}
src={`/v1/transcripts/${id}/audio/mp3${accessToken ? `?token=${accessToken}` : ''}`}
onTimeUpdate={handleTimeUpdate}
onEnded={() => setPlaying(false)}
preload="metadata"
/>
)}
{/* Waveform hover tooltip — fixed so it's never clipped by any overflow parent */}
{hoveredBar && (() => {
const t = (hoveredBar.index / sampledWaveform.length) * duration;
const seg = getSegmentAtTime(t);
return (
<div
className="fixed bg-on-surface text-surface text-xs px-2.5 py-1 rounded-lg whitespace-nowrap pointer-events-none z-[9999] shadow-md"
style={{ left: `${hoveredBar.x}px`, top: `${hoveredBar.y - 8}px`, transform: 'translate(-50%, -100%)' }}
>
{seg ? `Speaker ${seg.speaker}` : '—'} · {formatTime(t)}
</div>
);
})()}
{/* Player Bar */}
<div className="w-full bg-surface-high px-6 py-3.5 flex flex-col gap-3 sticky top-0 z-20 shadow-sm border-b border-outline-variant/20">
{audioDeleted ? (
<div className="flex items-center gap-3 text-muted text-sm py-1">
<VolumeX className="w-4 h-4 shrink-0" />
<span>Audio unavailable a participant opted out of audio retention.</span>
</div>
) : (
<div className="flex items-center gap-4">
<button
onClick={togglePlay}
title={isPlaying ? "Pause audio" : "Play audio"}
className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary hover:bg-primary/20 transition-colors shrink-0"
>
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-0.5" />}
</button>
<div className="flex-1 flex items-center gap-3">
<span className="text-xs font-mono text-muted shrink-0">{formatTime(currentTime)}</span>
{/* Waveform bars (with progress overlay) or fallback progress bar */}
<div
className="flex-1 h-14 flex items-end gap-[2px] cursor-pointer relative pb-1"
onClick={handleSeek}
onMouseMove={(e) => {
if (!sampledWaveform.length) return;
const rect = e.currentTarget.getBoundingClientRect();
const relX = e.clientX - rect.left;
const index = Math.min(
Math.floor((relX / rect.width) * sampledWaveform.length),
sampledWaveform.length - 1
);
setHoveredBar({ index, x: e.clientX, y: rect.top });
}}
onMouseLeave={() => setHoveredBar(null)}
>
{/* Topic title markers */}
{duration > 0 && (topicsData as any[])?.map((topic: any) => {
const posPercent = (topic.timestamp / duration) * 100;
if (posPercent < 0 || posPercent > 100) return null;
const truncated = topic.title?.length > 14
? topic.title.slice(0, 13) + '…'
: topic.title;
return (
<div
key={topic.id}
className="absolute top-0 flex flex-col items-start pointer-events-none select-none"
style={{ left: `${posPercent}%` }}
>
<span className="text-[10px] font-semibold text-on-surface-variant leading-none mb-[3px] whitespace-nowrap">
{truncated}
</span>
<div className="w-px h-2 bg-primary/50" />
</div>
);
})}
{sampledWaveform.length > 0 ? (
sampledWaveform.map((amplitude, i) => {
const barPercent = (i / sampledWaveform.length) * 100;
const isPast = barPercent < progressPercent;
const isHovered = hoveredBar?.index === i;
const barHeight = Math.max(3, Math.round(amplitude * 44));
return (
<div
key={i}
className="flex-1 rounded-full transition-colors"
style={{
height: `${barHeight}px`,
backgroundColor: isHovered
? '#DC5A28'
: isPast
? 'rgba(220, 90, 40, 0.7)'
: 'rgba(160, 154, 142, 0.35)',
}}
/>
);
})
) : (
<div className="w-full h-3 bg-surface-mid rounded-full relative overflow-hidden">
<div
className="absolute top-0 left-0 bottom-0 bg-primary/80 transition-all rounded-full pointer-events-none"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
</div>
<span className="text-xs font-mono text-muted shrink-0">{formatTime(duration)}</span>
</div>
</div>
)}
</div>
{/* Main Content */}
<main className="flex-1 min-h-0 flex flex-col lg:flex-row w-full max-w-[1600px] mx-auto overflow-hidden">
{/* Left Column: Summary */}
<div className="flex-1 lg:w-[55%] flex flex-col border-r border-outline-variant/10 overflow-y-auto min-h-0">
{/* Breadcrumb */}
<div className="p-4 border-b border-outline-variant/10">
<button
onClick={() => navigate('/transcriptions')}
title="Return to your transcripts archive"
className="flex items-center gap-2 text-muted hover:text-primary transition-colors text-sm font-medium"
>
<ChevronLeft className="w-4 h-4" />
Back to Archive
</button>
</div>
<div className="p-8 md:p-10 max-w-3xl mx-auto w-full">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="font-serif text-3xl font-bold text-on-surface">{displayTitle}</h1>
<div className="flex items-center gap-1 ml-1">
{[
{ icon: <Edit3 className="w-4 h-4" />, label: 'Edit title', onClick: undefined },
{ icon: <Copy className="w-4 h-4" />, label: 'Copy transcript', onClick: undefined },
{ icon: <Download className="w-4 h-4" />, label: 'Export as text', onClick: undefined },
{ icon: <Share2 className="w-4 h-4" />, label: 'Share transcript', onClick: undefined },
].map(({ icon, label, onClick }) => (
<div key={label} className="relative group/tip">
<button
onClick={onClick}
className="p-1.5 text-muted hover:text-on-surface transition-colors rounded hover:bg-surface-high"
>
{icon}
</button>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-on-surface text-surface text-[11px] rounded whitespace-nowrap pointer-events-none opacity-0 group-hover/tip:opacity-100 transition-opacity z-50 shadow-sm">
{label}
</div>
</div>
))}
</div>
</div>
<p className="text-sm text-muted flex items-center gap-2">
<span>{new Date(transcript.created_at).toLocaleDateString()}</span>
<span></span>
<span>{transcript.source_language?.toUpperCase() || 'EN'}</span>
</p>
</div>
{transcript.short_summary && (
<section className="mb-10 bg-surface-low p-6 rounded-xl border border-outline-variant/10 relative group shadow-sm">
<button title="Edit summary text" className="absolute top-4 right-4 p-1.5 text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all rounded-md hover:bg-surface-high">
<Edit3 className="w-4 h-4" />
</button>
<h3 className="text-xs font-bold uppercase tracking-widest text-muted mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span> Quick Recap
</h3>
<p className="font-serif text-[1.1rem] text-on-surface leading-relaxed">
{transcript.short_summary}
</p>
</section>
)}
{transcript.long_summary && (
<section>
<h3 className="text-xs font-bold uppercase tracking-widest text-muted mb-4">Executive Summary</h3>
<div className="space-y-4 text-on-surface-variant text-[0.9375rem] leading-relaxed whitespace-pre-wrap">
{transcript.long_summary}
</div>
</section>
)}
</div>
</div>
{/* Right Column: Chapters & Transcript */}
<div className="flex-1 lg:w-[45%] flex flex-col bg-surface-low overflow-hidden min-h-0">
{isCorrectionMode ? (
<CorrectionEditor
transcriptId={id}
topics={topicsData || []}
onClose={() => setIsCorrectionMode(false)}
/>
) : (
<>
<div className="p-6 border-b border-outline-variant/10 bg-surface/50 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h2 className="font-serif text-xl font-bold text-on-surface">Transcript</h2>
<div className="flex items-center gap-2">
{!audioDeleted && (
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded">Sync Active</span>
)}
<button onClick={() => setIsCorrectionMode(true)} className="ml-2 text-xs font-medium bg-surface-high hover:bg-surface text-on-surface px-3 py-1 border border-outline-variant/10 rounded flex items-center gap-1 transition-colors">
<Edit3 className="w-3 h-3" />
Correct
</button>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-20 py-2 bg-surface border border-outline-variant/20 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-sm text-on-surface placeholder:text-muted"
placeholder="Search in transcript..."
/>
{q && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[11px] text-muted font-medium">
{totalMatches} match{totalMatches !== 1 ? 'es' : ''}
</span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8 scroll-smooth" id="transcript-scroll-container">
{topicsLoading ? (
<div className="text-center text-muted font-medium text-sm pt-12">Loading conversation...</div>
) : filteredTopics.length > 0 ? (
filteredTopics.map((topic: any, idx: number) => {
// When searching, force-expand topics with matches
const isExpanded = q ? true : expandedChapters[topic.id] !== false;
const matchingSegmentStarts = new Set<number>(
(topic._matchingSegments ?? []).map((s: any) => s.start)
);
return (
<div key={topic.id} className="relative">
<div
className={`flex items-start gap-3 mb-4 cursor-pointer group hover:text-primary transition-colors ${isExpanded ? 'text-primary' : 'text-on-surface-variant'}`}
onClick={() => toggleChapter(topic.id)}
title={isExpanded ? "Collapse chapter" : "Expand chapter"}
>
<button className={`mt-0.5 p-0.5 rounded-sm transition-colors ${isExpanded ? 'bg-primary/10' : 'hover:bg-surface-high'}`}>
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
<div>
<span className="font-mono text-xs font-medium opacity-60 mb-0.5 block">{formatTime(topic.timestamp)}</span>
<h3 className="font-serif font-bold text-lg leading-snug">{highlight(topic.title || `Chapter ${idx + 1}`)}</h3>
</div>
</div>
{isExpanded && topic.segments && (
<div className="pl-9 space-y-6 relative before:absolute before:left-3.5 before:top-2 before:bottom-2 before:w-px before:bg-outline-variant/20">
{topic.segments.map((line: any, lIdx: number) => {
// When searching, dim segments that don't match
if (q && !matchingSegmentStarts.has(line.start)) return null;
// Sync active logic: matches current playback time to segment
const isActive = currentTime >= line.start && (!topic.segments[lIdx + 1] || currentTime < topic.segments[lIdx + 1].start);
if (isActive && isPlaying) {
setTimeout(() => {
document.getElementById(`line-${line.start}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
}
return (
<div key={`${line.start}-${lIdx}`} id={`line-${line.start}`} className="group relative">
{isActive && (
<div className="absolute -left-9 top-1.5 w-1.5 h-1.5 rounded-full bg-primary ring-4 ring-primary/10 transition-all shadow-sm shadow-primary"></div>
)}
<div className="flex items-baseline gap-3 mb-1">
<span className="text-[0.6875rem] font-bold uppercase tracking-wider text-muted group-hover:text-primary/70 transition-colors">
Speaker {line.speaker}
</span>
<span
onClick={() => jumpToTime(line.start)}
title="Jump to time"
className="text-[0.6875rem] font-mono text-muted opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:text-primary"
>
{formatTime(line.start)}
</span>
</div>
<p
onClick={() => jumpToTime(line.start)}
title="Jump to this segment"
className={`text-[0.9375rem] leading-relaxed cursor-pointer transition-colors ${isActive ? 'text-on-surface font-semibold bg-primary/5 rounded px-2 -mx-2 py-1' : 'text-on-surface-variant hover:text-on-surface'}`}
>
{highlight(line.text)}
</p>
</div>
);
})}
</div>
)}
</div>
)
})
) : q ? (
<div className="text-center text-muted font-medium text-sm pt-12">No results for "{searchQuery}"</div>
) : (
<div className="text-center text-muted font-medium text-sm pt-12">No transcription data available yet.</div>
)}
</div>
</>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,376 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranscriptsSearch, useTranscriptDelete, useRoomsList } from '../lib/apiHooks';
import type { components } from '../lib/reflector-api';
import { useAuth } from '../lib/AuthProvider';
import { useNavigate } from 'react-router-dom';
import { Button } from '../components/ui/Button';
import { ConfirmModal } from '../components/ui/ConfirmModal';
import { useQueryClient } from '@tanstack/react-query';
import {
Search,
FolderOpen,
Star,
Trash2,
MoreVertical,
Download,
Mic,
UploadCloud,
MicOff,
Globe,
Mail,
Calendar,
Clock,
Users
} from 'lucide-react';
export default function TranscriptionsPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [currentPage, setCurrentPage] = useState(0);
const PAGE_SIZE = 20;
const [activeSourceKind, setActiveSourceKind] = useState<components['schemas']['SourceKind'] | null>(null);
const [activeRoomId, setActiveRoomId] = useState<string | null>(null);
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false);
const queryClient = useQueryClient();
const deleteTranscriptMutation = useTranscriptDelete();
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const { data: roomsData } = useRoomsList(1);
const rooms = roomsData?.items || [];
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
const handleFilterChange = (sourceKind: components['schemas']['SourceKind'] | null, roomId: string | null) => {
setActiveSourceKind(sourceKind);
setActiveRoomId(roomId);
setCurrentPage(0);
};
const handleDeleteTranscript = () => {
if (!itemToDelete) return;
deleteTranscriptMutation.mutate(
{ params: { path: { transcript_id: itemToDelete } } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/v1/transcripts'] });
setItemToDelete(null);
},
}
);
};
const { data: transcriptsData, isLoading, isError } = useTranscriptsSearch(debouncedQuery, {
limit: PAGE_SIZE,
offset: currentPage * PAGE_SIZE,
room_id: activeRoomId || undefined,
source_kind: activeSourceKind || undefined,
});
const { register, watch } = useForm({
defaultValues: {
search: ''
}
});
const searchValue = watch('search');
// Debounce search input
React.useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedQuery(searchValue);
setCurrentPage(0);
}, 300);
return () => clearTimeout(timeoutId);
}, [searchValue]);
const displayTranscriptions = transcriptsData?.results ?? [];
return (
<div className="flex-1 bg-surface flex flex-col font-sans text-on-surface selection:bg-primary-fixed">
<main className="flex-1 p-8 md:p-12 max-w-7xl mx-auto w-full flex flex-col">
{/* Header */}
<div className="mb-6 border-b border-outline-variant/10 pb-6 shrink-0">
<h1 className="font-serif text-[1.75rem] font-bold text-on-surface leading-tight">
{auth.status === 'authenticated' && auth.user?.name ? `${auth.user.name}'s Transcriptions` : 'Your Transcriptions'}
</h1>
</div>
<div className="flex flex-col md:flex-row gap-6 md:gap-8 flex-1 items-start min-h-0">
<div className="w-full md:hidden">
<Button
type="button"
variant="secondary"
onClick={() => setIsMobileFiltersOpen(!isMobileFiltersOpen)}
className="w-full justify-center flex items-center gap-2 shadow-sm bg-surface-low"
>
{isMobileFiltersOpen ? 'Hide Filters' : 'Show Filters'}
</Button>
</div>
{/* Sidebar Filters */}
<aside className={`w-full md:w-56 shrink-0 bg-surface-low rounded-xl p-5 space-y-6 md:sticky md:top-8 border border-outline-variant/20 shadow-sm ${isMobileFiltersOpen ? 'block' : 'hidden md:block'}`}>
<button
onClick={() => handleFilterChange(null, null)}
className={`w-full text-left font-sans text-[0.9375rem] font-medium transition-colors ${!activeSourceKind && !activeRoomId ? 'text-primary' : 'text-on-surface-variant hover:text-primary'}`}
>
All Transcripts
</button>
<div className="w-full h-px bg-outline-variant/20" />
{myRooms.length > 0 && (
<div className="space-y-3">
<h3 className="font-sans text-[0.8125rem] font-bold text-on-surface tracking-wide uppercase">My Rooms</h3>
<div className="flex flex-col gap-2.5">
{myRooms.map(room => (
<button
key={room.id}
onClick={() => handleFilterChange('room', room.id)}
className={`text-left font-sans text-[0.9375rem] transition-colors truncate w-full ${activeSourceKind === 'room' && activeRoomId === room.id ? 'text-primary font-medium' : 'text-on-surface-variant hover:text-primary'}`}
>
{room.name}
</button>
))}
</div>
</div>
)}
{sharedRooms.length > 0 && (
<div className="space-y-3">
<h3 className="font-sans text-[0.8125rem] font-bold text-on-surface tracking-wide uppercase mt-4">Shared Rooms</h3>
<div className="flex flex-col gap-2.5">
{sharedRooms.map(room => (
<button
key={room.id}
onClick={() => handleFilterChange('room', room.id)}
className={`text-left font-sans text-[0.9375rem] transition-colors truncate w-full ${activeSourceKind === 'room' && activeRoomId === room.id ? 'text-primary font-medium' : 'text-on-surface-variant hover:text-primary'}`}
>
{room.name}
</button>
))}
</div>
</div>
)}
<div className="w-full h-px bg-outline-variant/20 mt-4" />
<div className="flex flex-col gap-3">
<button
onClick={() => handleFilterChange('live', null)}
className={`text-left font-sans text-[0.9375rem] transition-colors ${activeSourceKind === 'live' ? 'text-primary font-medium' : 'text-on-surface-variant hover:text-primary'}`}
>
Live Transcripts
</button>
<button
onClick={() => handleFilterChange('file', null)}
className={`text-left font-sans text-[0.9375rem] transition-colors ${activeSourceKind === 'file' ? 'text-primary font-medium' : 'text-on-surface-variant hover:text-primary'}`}
>
Uploaded Files
</button>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 w-full">
{/* Search */}
<form
className="flex items-center mb-6"
onSubmit={(e) => {
e.preventDefault();
setDebouncedQuery(searchValue);
setCurrentPage(0);
}}
>
<div className="relative group flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
{...register('search')}
className="pl-11 pr-4 py-3 w-full bg-surface-high border border-outline-variant/20 hover:border-outline-variant/40 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all text-[0.9375rem] text-on-surface placeholder:text-muted shadow-sm"
placeholder="Search transcriptions..."
/>
</div>
<Button type="submit" variant="primary" className="rounded-l-none py-3 px-6 shadow-sm border border-transparent">
Search
</Button>
</form>
{/* Pagination Controls */}
{(() => {
const totalCount = transcriptsData?.total || 0;
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
if (totalPages <= 1) return null;
// Simple sliding window
let startPage = Math.max(0, currentPage - 2);
let endPage = Math.min(totalPages - 1, currentPage + 2);
if (currentPage <= 2) {
endPage = Math.min(totalPages - 1, 4);
}
if (currentPage >= totalPages - 3) {
startPage = Math.max(0, totalPages - 5);
}
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return (
<div className="mb-6 flex items-center justify-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className={`w-8 h-8 flex items-center justify-center rounded border border-outline-variant/40 transition-colors ${currentPage === 0 ? 'opacity-50 cursor-not-allowed text-muted' : 'text-muted hover:bg-surface-high'}`}
>
<span className="text-lg leading-none mb-0.5"></span>
</button>
{startPage > 0 && (
<>
<button onClick={() => setCurrentPage(0)} className="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/40 text-on-surface-variant hover:bg-surface-high transition-colors font-sans text-sm font-medium bg-surface">1</button>
{startPage > 1 && <span className="px-1 text-muted text-sm">...</span>}
</>
)}
{pages.map(page => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-8 h-8 flex items-center justify-center rounded font-sans text-sm ${page === currentPage ? 'bg-primary text-white font-bold' : 'border border-outline-variant/40 text-on-surface-variant hover:bg-surface-high transition-colors font-medium bg-surface'}`}
>
{page + 1}
</button>
))}
{endPage < totalPages - 1 && (
<>
{endPage < totalPages - 2 && <span className="px-1 text-muted text-sm">...</span>}
<button onClick={() => setCurrentPage(totalPages - 1)} className="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/40 text-on-surface-variant hover:bg-surface-high transition-colors font-sans text-sm font-medium bg-surface">{totalPages}</button>
</>
)}
<button
onClick={() => setCurrentPage(Math.min(totalPages - 1, currentPage + 1))}
disabled={currentPage === totalPages - 1}
className={`w-8 h-8 flex items-center justify-center rounded border border-outline-variant/40 transition-colors ${currentPage === totalPages - 1 ? 'opacity-50 cursor-not-allowed text-muted' : 'text-muted hover:bg-surface-high'}`}
>
<span className="text-lg leading-none mb-0.5"></span>
</button>
</div>
);
})()}
{/* Transcription List */}
<div className="space-y-3">
{isLoading ? (
<div className="p-16 flex flex-col items-center justify-center border border-outline-variant/20 rounded-xl bg-surface">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin mb-4" />
<p className="text-sm text-muted">Loading transcriptions...</p>
</div>
) : isError ? (
<div className="p-16 flex flex-col items-center justify-center text-center border border-outline-variant/20 rounded-xl bg-surface">
<FolderOpen className="w-10 h-10 text-red-300 mb-4" strokeWidth={1.5} />
<p className="text-sm text-red-600">Failed to load transcriptions.</p>
</div>
) : displayTranscriptions.length === 0 ? (
<div className="p-16 flex flex-col items-center justify-center text-center border border-outline-variant/20 rounded-xl bg-surface">
<FolderOpen className="w-10 h-10 text-outline-variant mb-4" strokeWidth={1.5} />
<p className="font-serif italic text-on-surface-variant">No transcriptions found.</p>
</div>
) : (
displayTranscriptions.map((item) => (
<div
key={item.id}
onClick={() => navigate(`/transcriptions/${item.id}`)}
className="group flex items-center p-4 rounded-xl border border-outline-variant/20 hover:border-outline-variant/40 hover:bg-surface-high transition-colors cursor-pointer bg-surface shadow-sm"
>
<div className="flex items-center justify-center w-8 shrink-0">
<div className={`w-2.5 h-2.5 rounded-full ${item.status === 'ended' ? 'bg-primary' : item.status === 'error' ? 'bg-red-400' : 'bg-muted'}`}></div>
</div>
<div className="flex-1 px-3 min-w-0">
<h4 className="font-serif text-[1.0625rem] font-semibold text-on-surface group-hover:text-primary transition-colors truncate mb-1">
{item.title || 'Untitled Transcript'}
</h4>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[0.75rem] text-muted font-sans">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" /> {item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</span>
<span className="text-outline-variant/60"></span>
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" /> {item.room_name ?? 'Personal'}
</span>
<span className="text-outline-variant/60"></span>
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> {item.duration ? `${Math.round(item.duration / 60)}m` : '—'}
</span>
<span className="text-outline-variant/60"></span>
<span className="bg-surface-high px-2 py-0.5 rounded-md text-on-surface-variant font-medium">
{item.source_kind || 'upload'}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={(e) => e.stopPropagation()}
className="p-2 text-muted hover:text-primary hover:bg-primary/5 rounded-md transition-colors"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setItemToDelete(item.id); }}
className="p-2 text-muted hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Delete transcription"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={(e) => e.stopPropagation()}
className="p-2 text-muted hover:text-primary hover:bg-primary/5 rounded-md transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="mt-auto bg-surface-low py-8 px-8 flex flex-col md:flex-row justify-between items-center gap-4 border-t border-outline-variant/20">
<span className="text-[0.6875rem] font-medium text-on-surface-variant uppercase tracking-widest">
© 2024 Reflector Archive
</span>
<div className="flex items-center gap-6">
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Learn more</a>
<a href="#" className="text-sm text-on-surface-variant hover:text-primary transition-colors">Privacy policy</a>
</div>
</footer>
<ConfirmModal
isOpen={itemToDelete !== null}
onClose={() => setItemToDelete(null)}
onConfirm={handleDeleteTranscript}
title="Delete Transcription"
description="Are you sure you want to discard this transcription? This will permanently erase the transcript, its AI summaries, and any generated metadata."
confirmText="Delete"
isDestructive={true}
isLoading={deleteTranscriptMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,273 @@
import React, { useEffect, useState } from "react";
import { useParams, Navigate } from "react-router-dom";
import WherebyWebinarEmbed from "../components/WherebyWebinarEmbed";
import useRoomDefaultMeeting from "../hooks/rooms/useRoomDefaultMeeting";
import { CheckCircle2, Globe2 } from "lucide-react";
type FormData = {
name: string;
email: string;
company: string;
role: string;
};
const FORM_ID = "1hhtO6x9XacRwSZS-HRBLN9Ca_7iGZVpNX3_EC4I1uzc";
const FORM_FIELDS = {
name: "entry.1500809875",
email: "entry.1359095250",
company: "entry.1851914159",
role: "entry.1022377935",
};
export type Webinar = {
title: string;
startsAt: string;
endsAt: string;
};
enum WebinarStatus {
Upcoming = "upcoming",
Live = "live",
Ended = "ended",
}
const ROOM_NAME = "webinar";
// Mock database config from legacy V1
const WEBINARS: Webinar[] = [
{
title: "ai-operational-assistant",
startsAt: "2025-02-05T17:00:00Z",
endsAt: "2025-02-05T18:00:00Z",
},
{
title: "ai-operational-assistant-dry-run",
startsAt: "2025-02-05T02:30:00Z",
endsAt: "2025-02-05T03:10:00Z",
},
];
export default function WebinarLandingPage() {
const { title } = useParams<{ title: string }>();
const webinar = WEBINARS.find((w) => w.title === title);
const meeting = useRoomDefaultMeeting(ROOM_NAME);
const roomUrl = meeting?.response?.host_room_url || meeting?.response?.room_url;
const [status, setStatus] = useState<WebinarStatus>(WebinarStatus.Ended);
const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
const [formSubmitted, setFormSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({ name: "", email: "", company: "", role: "" });
useEffect(() => {
if (!webinar) return;
const startDate = new Date(Date.parse(webinar.startsAt));
const endDate = new Date(Date.parse(webinar.endsAt));
const updateCountdown = () => {
const now = new Date();
if (now < startDate) {
setStatus(WebinarStatus.Upcoming);
const difference = startDate.getTime() - now.getTime();
setCountdown({
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60),
});
} else if (now < endDate) {
setStatus(WebinarStatus.Live);
} else {
setStatus(WebinarStatus.Ended);
}
};
updateCountdown();
const timer = setInterval(updateCountdown, 1000);
return () => clearInterval(timer);
}, [webinar]);
if (!webinar) return <Navigate to="/welcome" replace />;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const submitUrl = `https://docs.google.com/forms/d/${FORM_ID}/formResponse`;
const data = Object.entries(FORM_FIELDS).map(([key, value]) => {
return `${value}=${encodeURIComponent(formData[key as keyof FormData])}`;
}).join("&");
await fetch(submitUrl, {
method: "POST",
mode: "no-cors",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: data,
});
setFormSubmitted(true);
} catch (error) {
console.error("Error submitting form:", error);
} finally {
setIsSubmitting(false);
}
};
const handleLeave = () => {
window.location.reload();
};
// ──── Live View Render ─────────────────────────────────────────────
if (status === WebinarStatus.Live) {
return (
<div className="w-full h-screen bg-surface">
{roomUrl ? (
<WherebyWebinarEmbed roomUrl={roomUrl} onLeave={handleLeave} />
) : (
<div className="flex h-full items-center justify-center text-muted font-medium">Preparing webinar stream...</div>
)}
</div>
);
}
// ──── Ended OR Upcoming View Render ─────────────────────────────────────────────
const isEnded = status === WebinarStatus.Ended;
const badgeText = isEnded ? "FREE RECORDING" : "FREE WEBINAR";
const dateText = new Date(Date.parse(webinar.startsAt)).toLocaleString('en-US', {
weekday: 'long', month: 'long', day: 'numeric',
hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
});
return (
<div className="min-h-screen bg-surface-low py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-4xl mx-auto bg-surface rounded-3xl shadow-xl overflow-hidden border border-outline-variant/20">
{/* Banner Headers */}
<div className="px-6 py-12 md:px-20 lg:px-32 text-center">
<div className="flex justify-center mb-8">
<Globe2 className="w-12 h-12 text-primary" />
</div>
<div className="inline-block bg-primary/10 text-primary text-xs font-bold tracking-widest uppercase px-3 py-1 rounded-full mb-6">
{badgeText}
</div>
<h1 className="text-4xl md:text-5xl font-serif font-bold text-on-surface leading-tight mb-4">
Building AI-Powered<br className="hidden md:block" /> Operational Assistants
</h1>
<p className="text-lg text-muted mb-8">From Simple Automation to Strategic Implementation</p>
{!isEnded && (
<>
<p className="font-semibold text-on-surface mb-6">{dateText}</p>
<div className="flex justify-center gap-4 mb-12">
{[
{ value: countdown.days, label: "DAYS" },
{ value: countdown.hours, label: "HOURS" },
{ value: countdown.minutes, label: "MIN" },
{ value: countdown.seconds, label: "SEC" },
].map((item, idx) => (
<div key={idx} className="bg-surface-high border border-outline-variant/30 shadow-sm rounded-xl p-4 w-20 md:w-24">
<div className="text-3xl md:text-4xl font-bold font-mono text-on-surface mb-1">{item.value.toString().padStart(2, '0')}</div>
<div className="text-[10px] md:text-xs font-bold text-primary tracking-wider">{item.label}</div>
</div>
))}
</div>
</>
)}
{isEnded && (
<div className="relative aspect-video rounded-xl overflow-hidden shadow-lg border border-outline-variant/10 mb-12 bg-surface-high flex items-center justify-center group cursor-pointer" onClick={() => document.getElementById('register-form')?.scrollIntoView({ behavior: 'smooth'})}>
{/* Note: Replacing dummy next/image with straight img/div */}
<img src="/webinar-preview.png" alt="Video Preview" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 opacity-90" onError={(e) => (e.currentTarget.style.display = 'none')} />
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center text-white shadow-lg">
<CheckCircle2 className="w-8 h-8" />
</div>
</div>
</div>
)}
<button
onClick={() => document.getElementById('register-form')?.scrollIntoView({ behavior: "smooth" })}
className="w-full max-w-sm mx-auto py-4 px-8 bg-primary hover:bg-primary-hover active:bg-primary-active text-on-primary font-bold tracking-wide rounded-full shadow-lg hover:shadow-xl transition-all uppercase"
>
{isEnded ? "Get Instant Access" : "RSVP Here"}
</button>
</div>
<hr className="border-outline-variant/10" />
{/* Informational Content Grid */}
<div className="px-6 py-16 md:px-20 lg:px-32 bg-surface text-on-surface text-lg leading-relaxed space-y-12">
<div className="space-y-6">
<p>
{isEnded
? "The hype around AI agents might be a little premature. But operational assistants are very real, available today, and can unlock your team to do their best work."
: "AI is ready to deliver value to your organization, but it's not ready to act autonomously. The highest-value applications of AI today are assistants, which significantly increase the efficiency of workers in operational roles."}
</p>
<p>
In this session, we dive into what operational assistants are and how you can implement them in your organization to deliver real, tangible value.
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-bold mb-6">What We Cover:</h2>
<ul className="space-y-4">
{[
"What an AI operational assistant is (and isn't).",
"Example use cases for how they can be implemented across your organization.",
"Key security and design considerations to avoid sharing sensitive data with outside platforms.",
"Live demos showing both entry-level and advanced implementations.",
"How you can start implementing them to immediately unlock value.",
].map((item, index) => (
<li key={index} className="flex gap-3">
<CheckCircle2 className="w-6 h-6 shrink-0 text-primary" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{/* Contact / Registration Form */}
<div id="register-form" className="bg-surface-high border border-primary/20 p-8 md:p-10 rounded-2xl shadow-sm mt-12">
<h2 className="font-serif text-2xl font-bold mb-6 text-center">
{isEnded ? "To Watch This Recording, Fill Out the Brief Form Below:" : "Register for the Live Webinar Event:"}
</h2>
{formSubmitted ? (
<div className="bg-primary/10 border border-primary/30 p-6 rounded-xl flex items-start gap-4 text-primary font-medium">
<CheckCircle2 className="w-8 h-8 shrink-0" />
<p>Thanks for signing up! {isEnded ? "Check your email. We'll send you the recording link immediately." : "You're registered. We'll email you a reminder before the broadcast begins."}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm mx-auto">
<input required type="text" placeholder="Your Name" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-outline-variant/30 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all" />
<input required type="email" placeholder="Your Email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-outline-variant/30 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all" />
<input required type="text" placeholder="Company Name" value={formData.company} onChange={(e) => setFormData({ ...formData, company: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-outline-variant/30 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all" />
<input required type="text" placeholder="Your Role" value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value })} className="w-full px-4 py-3 rounded-lg border border-outline-variant/30 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all" />
<button type="submit" disabled={isSubmitting} className="w-full mt-4 py-4 px-6 bg-primary text-white font-bold rounded-full disabled:opacity-50 hover:bg-primary/90 transition-colors uppercase tracking-widest">
{isSubmitting ? "Submitting..." : "Get Instant Access"}
</button>
</form>
)}
</div>
</div>
{/* Footer Sponsor Block */}
<div className="bg-surface-low text-center py-16">
<p className="text-xs font-bold tracking-widest text-muted uppercase mb-4">POWERED BY</p>
<div className="flex flex-col items-center justify-center opacity-80 decoration-transparent hover:opacity-100 transition-opacity">
<h1 className="font-serif text-3xl font-bold mb-1">Reflector</h1>
<p className="text-sm font-medium text-muted">Capture the signal, not the noise</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Settings, Mic, Upload, Sparkles, CircleDot } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Select } from '../components/ui/Select';
import { Card } from '../components/ui/Card';
import { FieldError } from '../components/ui/FieldError';
import { useNavigate, Link } from 'react-router-dom';
import { useTranscriptCreate } from '../lib/apiHooks';
import { useAudioDevice } from '../hooks/useAudioDevice';
import { supportedLanguages } from '../lib/supportedLanguages';
const sourceLanguages = supportedLanguages.filter(
(l) => l.value && l.value !== 'NOTRANSLATION',
);
type TryReflectorForm = {
meetingTitle: string;
sourceLanguage: string;
targetLanguage: string;
};
export default function WelcomePage() {
const navigate = useNavigate();
const { register, handleSubmit, formState: { errors } } = useForm<TryReflectorForm>({
defaultValues: {
meetingTitle: '',
sourceLanguage: 'en',
targetLanguage: 'NOTRANSLATION',
}
});
const { loading: permissionLoading, permissionOk, permissionDenied, requestPermission } = useAudioDevice();
const transcriptMutation = useTranscriptCreate();
const [loadingRecord, setLoadingRecord] = useState(false);
const [loadingUpload, setLoadingUpload] = useState(false);
const onSubmit = (data: TryReflectorForm, sourceKind: 'live' | 'file') => {
if (loadingRecord || loadingUpload || transcriptMutation.isPending || permissionDenied) return;
if (sourceKind === 'live') setLoadingRecord(true);
else setLoadingUpload(true);
transcriptMutation.mutate({
body: {
name: data.meetingTitle || 'Untitled Recording',
source_language: data.sourceLanguage || 'en',
target_language: data.targetLanguage === 'NOTRANSLATION' ? undefined : data.targetLanguage,
source_kind: sourceKind
}
}, {
onSuccess: (res) => {
// Upon success, navigate explicitly to the transcript view
navigate(`/transcriptions/${res.id}`);
},
onError: () => {
setLoadingRecord(false);
setLoadingUpload(false);
}
});
};
const isFormLoading = loadingRecord || loadingUpload || transcriptMutation.isPending;
return (
<div className="flex-1 bg-surface flex flex-col font-sans text-on-surface selection:bg-primary-fixed">
{/* Main Content */}
<main className="flex-1 flex flex-col px-6 pt-16 pb-24">
<div className="w-full max-w-6xl mx-auto space-y-24">
{/* Top Section: Two Columns */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 items-center">
{/* Left Column: Hero Copy */}
<div className="lg:col-span-7 space-y-8">
<h1 className="text-[2.5rem] md:text-[3.5rem] font-serif font-bold text-on-surface leading-[1.1] tracking-tight">
Welcome to Reflector
</h1>
<p className="text-[0.9375rem] text-on-surface-variant max-w-[440px] leading-[1.6]">
Reflector is a transcription and summarization pipeline that
transforms audio into knowledge. The output is meeting minutes and topic summaries enabling
topic-specific analyses stored in your systems of record. This is
accomplished on your infrastructure without 3rd parties
keeping your data private, secure, and organized.
</p>
<p className="text-[0.9375rem] text-on-surface-variant max-w-[440px] leading-[1.6]">
In order to use Reflector, we kindly request permission to access
your microphone during meetings and events.
</p>
<div className="flex items-center gap-4 pt-4">
<Button variant="secondary" onClick={() => navigate('/transcriptions')}>Archive</Button>
<Button variant="secondary" onClick={() => navigate('/rooms')}>Rooms</Button>
</div>
</div>
{/* Right Column: Try Reflector Widget */}
<div className="lg:col-span-5">
<Card className="p-7">
<form className="space-y-6">
<div className="flex items-center justify-between mb-2">
<h2 className="font-serif font-bold text-xl">Try Reflector</h2>
<button type="button" className="text-muted hover:text-on-surface transition-colors">
<Settings className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block font-sans text-[0.75rem] font-semibold text-on-surface-variant uppercase tracking-wider">
Recording Name
</label>
<Input
{...register('meetingTitle')}
placeholder="Optional"
className="w-full"
/>
<FieldError message={errors.meetingTitle?.message} />
</div>
<div className="space-y-1.5">
<label className="block font-sans text-[0.75rem] font-semibold text-on-surface-variant uppercase tracking-wider">
Audio Language
</label>
<Select
{...register('sourceLanguage')}
className="w-full font-medium"
>
{sourceLanguages.map(lang => (
<option key={lang.value} value={lang.value}>{lang.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="block font-sans text-[0.75rem] font-semibold text-on-surface-variant uppercase tracking-wider">
Live Translation
</label>
<Select
{...register('targetLanguage')}
className="w-full font-medium"
>
{supportedLanguages.map(lang => (
<option key={lang.value} value={lang.value}>{lang.name}</option>
))}
</Select>
<FieldError message={errors.targetLanguage?.message} />
</div>
</div>
{/* Permission / Action Buttons */}
{!permissionLoading ? (
permissionOk ? (
<div className="pt-2 text-[0.75rem] text-green-700 font-medium"> Microphone access granted</div>
) : permissionDenied ? (
<div className="pt-2 text-[0.85rem] text-red-600">
Permission to use your microphone was denied, please turn it on in your browser settings and refresh.
</div>
) : (
<Button
type="button"
variant="primary"
onClick={requestPermission}
disabled={permissionDenied}
className="w-full flex items-center justify-center gap-2 py-3 text-[0.9375rem]"
>
<Mic className="w-4 h-4" />
Request Microphone Permission
</Button>
)
) : (
<div className="pt-2 text-[0.85rem] text-muted">Checking permissions...</div>
)}
<div className="relative flex items-center py-2">
<div className="flex-grow border-t border-surface-high"></div>
<span className="flex-shrink-0 mx-4 text-[0.6875rem] text-muted uppercase tracking-widest font-medium">OR</span>
<div className="flex-grow border-t border-surface-high"></div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
onClick={handleSubmit((data) => onSubmit(data, 'live'))}
disabled={!permissionOk || isFormLoading}
variant="primary"
className="flex flex-col items-center justify-center gap-2 py-4 h-auto"
>
<CircleDot className="w-5 h-5" />
<span className="text-xs">{loadingRecord ? "Starting..." : "Record Meeting"}</span>
</Button>
<Button
type="button"
variant="secondary"
onClick={handleSubmit((data) => onSubmit(data, 'file'))}
disabled={isFormLoading}
className="flex flex-col items-center justify-center gap-2 py-4 h-auto border-outline-variant/40 text-on-surface hover:bg-surface-mid"
>
<Upload className="w-5 h-5" />
<span className="text-xs">{loadingUpload ? "Preparing..." : "Upload File"}</span>
</Button>
</div>
</form>
</Card>
</div>
</div>
{/* Feature Cards Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
{/* Card 1 */}
<div className="relative h-[320px] rounded-md overflow-hidden group">
<img
src="https://images.unsplash.com/photo-1507842217343-583bb7270b66?q=80&w=2400&auto=format&fit=crop"
alt="Library aesthetic"
className="absolute inset-0 w-full h-full object-cover grayscale-[30%] group-hover:scale-105 transition-transform duration-700"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
<div className="absolute bottom-0 left-0 p-8 space-y-2">
<h3 className="font-serif italic text-2xl text-white">The Editorial standard for your audio.</h3>
<p className="text-white/80 text-sm max-w-[280px] leading-relaxed">
Our curation engine doesn't just transcribe; it captures the essence, tone, and authority of your spoken words.
</p>
</div>
</div>
{/* Card 2 */}
<div className="bg-gradient-primary rounded-md p-10 flex flex-col justify-center relative overflow-hidden">
<div className="absolute -right-12 -top-12 w-64 h-64 bg-white/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 space-y-6">
<Sparkles className="w-10 h-10 text-white" />
<div className="space-y-3">
<h3 className="font-serif font-bold text-3xl text-white">AI Synthesis</h3>
<p className="text-white/85 text-[0.9375rem] leading-relaxed max-w-[320px]">
Turn hours of live discussion into a structured archive of actionable insight and creative sparks.
</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,28 @@
/**
* Auth store — simplified to hold only client-side state.
* Real auth logic lives in AuthProvider (communicates with Express proxy).
* This store is mainly used for reactive UI updates and persisting a
* flag for the RequireAuth guard during initial load.
*/
import { create } from "zustand";
interface AuthUser {
id: string;
name?: string | null;
email?: string | null;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
setAuth: (user: AuthUser) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
isAuthenticated: false,
setAuth: (user) => set({ user, isAuthenticated: true }),
clearAuth: () => set({ user: null, isAuthenticated: false }),
}));

View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface PlayerStore {
isPlaying: boolean;
currentTime: number;
activeChapterId: string | null;
setPlaying: (v: boolean) => void;
setCurrentTime: (t: number) => void;
setActiveChapter: (id: string | null) => void;
}
export const usePlayerStore = create<PlayerStore>((set) => ({
isPlaying: false,
currentTime: 0,
activeChapterId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
setCurrentTime: (currentTime) => set({ currentTime }),
setActiveChapter: (activeChapterId) => set({ activeChapterId }),
}));

View File

@@ -0,0 +1,15 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
@import './tokens.css';
@layer base {
*, *::before, *::after { box-sizing: border-box; }
body {
background-color: var(--color-surface);
color: var(--color-on-surface);
font-family: 'Manrope', sans-serif;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: var(--color-outline-variant); border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; }
}

View File

@@ -0,0 +1,23 @@
:root {
--color-primary: #DC5A28;
--color-primary-dark: #a63500;
--color-primary-container: #c84c1a;
--color-on-primary: #FFFFFF;
--color-surface: #fcfaec;
--color-surface-container-low: #f6f4e7;
--color-surface-container: #f0eee1;
--color-surface-container-high: #e8e5d4;
--color-surface-container-highest: #FFFFFF;
--color-on-surface: #1b1c14;
--color-on-surface-variant: #5a5850;
--color-outline-variant: #e0bfb5;
--color-error: #ba1a1a;
--gradient-primary: linear-gradient(135deg, #a63500, #c84c1a);
/* Spacing */
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px;
--space-12: 48px; --space-16: 64px;
/* Radius */
--radius-sm: 6px;
--radius-md: 12px;
}

View File

@@ -0,0 +1,5 @@
export interface User { id: string; name: string; email: string; avatarUrl?: string }
export interface Room { id: string; name: string; platform: string; recordingType: string; size: string; zulipStream?: string; isLocked: boolean; isShared: boolean }
export interface Chapter { id: string; title: string; timestamp: string; excerpt?: string }
export interface Transcription { id: string; title: string; roomId: string; date: string; duration: string; speakerCount: number; status: 'processed' | 'processing'; chapters: Chapter[]; quickRecap: string; summary: string }
export interface ApiKey { id: string; name: string; prefix: string; createdAt: string; lastUsed?: string }

19
www/appv2/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_WEBSOCKET_URL?: string;
readonly VITE_AUTH_PROXY_URL?: string;
readonly VITE_SITE_URL?: string;
readonly VITE_SENTRY_DSN?: string;
readonly VITE_FEATURE_REQUIRE_LOGIN?: string;
readonly VITE_FEATURE_PRIVACY?: string;
readonly VITE_FEATURE_BROWSE?: string;
readonly VITE_FEATURE_SEND_TO_ZULIP?: string;
readonly VITE_FEATURE_ROOMS?: string;
readonly VITE_FEATURE_EMAIL_TRANSCRIPT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,38 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
primary: '#DC5A28',
'primary-dark': '#a63500',
'primary-container': '#c84c1a',
surface: '#fcfaec',
'surface-low': '#f6f4e7',
'surface-mid': '#f0eee1',
'surface-high': '#e8e5d4',
'on-surface': '#1b1c14',
'on-surface-variant': '#5a5850',
muted: '#a09a8e',
'outline-variant': '#e0bfb5',
error: '#ba1a1a',
},
fontFamily: {
serif: ['Newsreader', 'Georgia', 'serif'],
sans: ['Manrope', 'system-ui', 'sans-serif'],
},
borderRadius: {
sm: '6px',
md: '12px',
},
backgroundImage: {
'gradient-primary': 'linear-gradient(135deg, #a63500, #c84c1a)',
},
boxShadow: {
card: '0 8px 40px rgba(27,28,20,0.06)',
modal: '0 16px 48px rgba(27,28,20,0.12)',
},
},
},
plugins: [],
}

26
www/appv2/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

38
www/appv2/vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
port: 3000,
host: '0.0.0.0',
// HMR disabled via env var for AI Studio
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
// API requests → backend
'/v1': {
target: env.SERVER_API_URL || 'http://localhost:1250',
changeOrigin: true,
ws: true,
},
// Auth proxy requests → Express auth server
'/auth': {
target: env.AUTH_PROXY_URL || 'http://localhost:3001',
changeOrigin: true,
},
},
},
};
});

3444
www/appv2/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -81,5 +81,6 @@
"immutable": "5.1.5",
"next": "16.1.7"
}
}
},
"packageManager": "pnpm@10.33.0"
}