From dc428b20429787c4e44b7f5e19f49a3d41b8a78b Mon Sep 17 00:00:00 2001 From: Kevin Guevara <34045043+kevdevg@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:25:19 -0500 Subject: [PATCH] adding app v2 (#943) --- .gitignore | 2 + docker-compose.yml | 20 +- .../processors/audio_transcript_modal.py | 3 +- www/app/lib/apiHooks.ts | 1 + www/appv2/.env.example | 30 + www/appv2/README.md | 20 + www/appv2/dist/assets/index-BSIeQkMT.js | 282 + www/appv2/dist/assets/index-DT0hy75l.css | 1 + www/appv2/dist/index.html | 14 + www/appv2/index.html | 13 + www/appv2/metadata.json | 5 + www/appv2/package-lock.json | 6888 +++++++++++++++++ www/appv2/package.json | 59 + www/appv2/postcss.config.js | 6 + www/appv2/server/auth.ts | 28 + www/appv2/server/index.ts | 354 + www/appv2/src/App.tsx | 138 + www/appv2/src/api/_error.ts | 26 + .../src/components/WherebyWebinarEmbed.tsx | 84 + www/appv2/src/components/layout/Footer.tsx | 16 + www/appv2/src/components/layout/TopNav.tsx | 123 + .../src/components/rooms/AddRoomModal.tsx | 462 ++ www/appv2/src/components/rooms/DailyRoom.tsx | 284 + .../components/rooms/MeetingMinimalHeader.tsx | 75 + .../src/components/rooms/MeetingSelection.tsx | 363 + .../src/components/rooms/WherebyRoom.tsx | 127 + .../components/transcripts/ProcessingView.tsx | 28 + .../src/components/transcripts/RecordView.tsx | 255 + .../src/components/transcripts/UploadView.tsx | 134 + .../correction/CorrectionEditor.tsx | 139 + .../correction/ParticipantSidebar.tsx | 331 + .../correction/TopicWordsEditor.tsx | 170 + .../transcripts/correction/types.ts | 21 + www/appv2/src/components/ui/Button.tsx | 29 + www/appv2/src/components/ui/Card.tsx | 19 + www/appv2/src/components/ui/Checkbox.tsx | 25 + www/appv2/src/components/ui/ConfirmModal.tsx | 96 + www/appv2/src/components/ui/FieldError.tsx | 15 + www/appv2/src/components/ui/Input.tsx | 17 + www/appv2/src/components/ui/Select.tsx | 19 + .../src/hooks/rooms/useRoomDefaultMeeting.ts | 88 + www/appv2/src/hooks/transcripts/useWebRTC.ts | 85 + .../src/hooks/transcripts/useWebSockets.ts | 173 + .../hooks/transcripts/webSocketReconnect.ts | 5 + .../src/hooks/transcripts/webSocketTypes.ts | 24 + www/appv2/src/hooks/useAudioDevice.ts | 134 + www/appv2/src/lib/AuthProvider.tsx | 271 + www/appv2/src/lib/UserEventsProvider.tsx | 186 + www/appv2/src/lib/apiClient.ts | 94 + www/appv2/src/lib/apiHooks.ts | 967 +++ www/appv2/src/lib/array.ts | 12 + www/appv2/src/lib/consent/ConsentDialog.tsx | 45 + .../src/lib/consent/ConsentDialogButton.tsx | 27 + .../src/lib/consent/RecordingIndicator.tsx | 15 + www/appv2/src/lib/consent/constants.ts | 12 + www/appv2/src/lib/consent/index.ts | 7 + www/appv2/src/lib/consent/types.ts | 14 + .../src/lib/consent/useConsentDialog.tsx | 82 + www/appv2/src/lib/consent/utils.ts | 10 + www/appv2/src/lib/errorContext.tsx | 91 + www/appv2/src/lib/errorUtils.ts | 49 + www/appv2/src/lib/features.ts | 44 + www/appv2/src/lib/queryClient.ts | 15 + www/appv2/src/lib/recordingConsentContext.tsx | 153 + www/appv2/src/lib/reflector-api.d.ts | 4421 +++++++++++ www/appv2/src/lib/sentry.ts | 20 + www/appv2/src/lib/supportedLanguages.ts | 492 ++ www/appv2/src/lib/timeUtils.ts | 25 + www/appv2/src/lib/types.ts | 13 + www/appv2/src/lib/utils.ts | 185 + www/appv2/src/lib/wherebyClient.ts | 26 + www/appv2/src/main.tsx | 16 + www/appv2/src/pages/AboutPage.tsx | 55 + www/appv2/src/pages/LoginPage.tsx | 223 + www/appv2/src/pages/PrivacyPage.tsx | 44 + www/appv2/src/pages/RoomMeetingPage.tsx | 145 + www/appv2/src/pages/RoomsPage.tsx | 351 + www/appv2/src/pages/SettingsPage.tsx | 258 + .../src/pages/SingleTranscriptionPage.tsx | 554 ++ www/appv2/src/pages/TranscriptionsPage.tsx | 376 + www/appv2/src/pages/WebinarLandingPage.tsx | 273 + www/appv2/src/pages/WelcomePage.tsx | 252 + www/appv2/src/stores/useAuthStore.ts | 28 + www/appv2/src/stores/usePlayerStore.ts | 19 + www/appv2/src/styles/global.css | 15 + www/appv2/src/styles/tokens.css | 23 + www/appv2/src/types/index.ts | 5 + www/appv2/src/vite-env.d.ts | 19 + www/appv2/tailwind.config.js | 38 + www/appv2/tsconfig.json | 26 + www/appv2/vite.config.ts | 38 + www/appv2/yarn.lock | 3444 +++++++++ www/package.json | 3 +- 93 files changed, 24703 insertions(+), 9 deletions(-) create mode 100644 www/appv2/.env.example create mode 100644 www/appv2/README.md create mode 100644 www/appv2/dist/assets/index-BSIeQkMT.js create mode 100644 www/appv2/dist/assets/index-DT0hy75l.css create mode 100644 www/appv2/dist/index.html create mode 100644 www/appv2/index.html create mode 100644 www/appv2/metadata.json create mode 100644 www/appv2/package-lock.json create mode 100644 www/appv2/package.json create mode 100644 www/appv2/postcss.config.js create mode 100644 www/appv2/server/auth.ts create mode 100644 www/appv2/server/index.ts create mode 100644 www/appv2/src/App.tsx create mode 100644 www/appv2/src/api/_error.ts create mode 100644 www/appv2/src/components/WherebyWebinarEmbed.tsx create mode 100644 www/appv2/src/components/layout/Footer.tsx create mode 100644 www/appv2/src/components/layout/TopNav.tsx create mode 100644 www/appv2/src/components/rooms/AddRoomModal.tsx create mode 100644 www/appv2/src/components/rooms/DailyRoom.tsx create mode 100644 www/appv2/src/components/rooms/MeetingMinimalHeader.tsx create mode 100644 www/appv2/src/components/rooms/MeetingSelection.tsx create mode 100644 www/appv2/src/components/rooms/WherebyRoom.tsx create mode 100644 www/appv2/src/components/transcripts/ProcessingView.tsx create mode 100644 www/appv2/src/components/transcripts/RecordView.tsx create mode 100644 www/appv2/src/components/transcripts/UploadView.tsx create mode 100644 www/appv2/src/components/transcripts/correction/CorrectionEditor.tsx create mode 100644 www/appv2/src/components/transcripts/correction/ParticipantSidebar.tsx create mode 100644 www/appv2/src/components/transcripts/correction/TopicWordsEditor.tsx create mode 100644 www/appv2/src/components/transcripts/correction/types.ts create mode 100644 www/appv2/src/components/ui/Button.tsx create mode 100644 www/appv2/src/components/ui/Card.tsx create mode 100644 www/appv2/src/components/ui/Checkbox.tsx create mode 100644 www/appv2/src/components/ui/ConfirmModal.tsx create mode 100644 www/appv2/src/components/ui/FieldError.tsx create mode 100644 www/appv2/src/components/ui/Input.tsx create mode 100644 www/appv2/src/components/ui/Select.tsx create mode 100644 www/appv2/src/hooks/rooms/useRoomDefaultMeeting.ts create mode 100644 www/appv2/src/hooks/transcripts/useWebRTC.ts create mode 100644 www/appv2/src/hooks/transcripts/useWebSockets.ts create mode 100644 www/appv2/src/hooks/transcripts/webSocketReconnect.ts create mode 100644 www/appv2/src/hooks/transcripts/webSocketTypes.ts create mode 100644 www/appv2/src/hooks/useAudioDevice.ts create mode 100644 www/appv2/src/lib/AuthProvider.tsx create mode 100644 www/appv2/src/lib/UserEventsProvider.tsx create mode 100644 www/appv2/src/lib/apiClient.ts create mode 100644 www/appv2/src/lib/apiHooks.ts create mode 100644 www/appv2/src/lib/array.ts create mode 100644 www/appv2/src/lib/consent/ConsentDialog.tsx create mode 100644 www/appv2/src/lib/consent/ConsentDialogButton.tsx create mode 100644 www/appv2/src/lib/consent/RecordingIndicator.tsx create mode 100644 www/appv2/src/lib/consent/constants.ts create mode 100644 www/appv2/src/lib/consent/index.ts create mode 100644 www/appv2/src/lib/consent/types.ts create mode 100644 www/appv2/src/lib/consent/useConsentDialog.tsx create mode 100644 www/appv2/src/lib/consent/utils.ts create mode 100644 www/appv2/src/lib/errorContext.tsx create mode 100644 www/appv2/src/lib/errorUtils.ts create mode 100644 www/appv2/src/lib/features.ts create mode 100644 www/appv2/src/lib/queryClient.ts create mode 100644 www/appv2/src/lib/recordingConsentContext.tsx create mode 100644 www/appv2/src/lib/reflector-api.d.ts create mode 100644 www/appv2/src/lib/sentry.ts create mode 100644 www/appv2/src/lib/supportedLanguages.ts create mode 100644 www/appv2/src/lib/timeUtils.ts create mode 100644 www/appv2/src/lib/types.ts create mode 100644 www/appv2/src/lib/utils.ts create mode 100644 www/appv2/src/lib/wherebyClient.ts create mode 100644 www/appv2/src/main.tsx create mode 100644 www/appv2/src/pages/AboutPage.tsx create mode 100644 www/appv2/src/pages/LoginPage.tsx create mode 100644 www/appv2/src/pages/PrivacyPage.tsx create mode 100644 www/appv2/src/pages/RoomMeetingPage.tsx create mode 100644 www/appv2/src/pages/RoomsPage.tsx create mode 100644 www/appv2/src/pages/SettingsPage.tsx create mode 100644 www/appv2/src/pages/SingleTranscriptionPage.tsx create mode 100644 www/appv2/src/pages/TranscriptionsPage.tsx create mode 100644 www/appv2/src/pages/WebinarLandingPage.tsx create mode 100644 www/appv2/src/pages/WelcomePage.tsx create mode 100644 www/appv2/src/stores/useAuthStore.ts create mode 100644 www/appv2/src/stores/usePlayerStore.ts create mode 100644 www/appv2/src/styles/global.css create mode 100644 www/appv2/src/styles/tokens.css create mode 100644 www/appv2/src/types/index.ts create mode 100644 www/appv2/src/vite-env.d.ts create mode 100644 www/appv2/tailwind.config.js create mode 100644 www/appv2/tsconfig.json create mode 100644 www/appv2/vite.config.ts create mode 100644 www/appv2/yarn.lock diff --git a/.gitignore b/.gitignore index b244fd4f..36740199 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ Caddyfile.gpu-host .env.gpu-host vibedocs/ server/tests/integration/logs/ +node_modules +node_modules diff --git a/docker-compose.yml b/docker-compose.yml index ebc34705..dd4386c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/server/reflector/processors/audio_transcript_modal.py b/server/reflector/processors/audio_transcript_modal.py index 6ce19ea1..cac333de 100644 --- a/server/reflector/processors/audio_transcript_modal.py +++ b/server/reflector/processors/audio_transcript_modal.py @@ -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, diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 4ece77e2..b7d0a673 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -361,6 +361,7 @@ export function useTranscriptUploadAudio() { }); }, onError: (error) => { + console.log(error) setError(error as Error, "There was an error uploading the audio file"); }, }, diff --git a/www/appv2/.env.example b/www/appv2/.env.example new file mode 100644 index 00000000..35821605 --- /dev/null +++ b/www/appv2/.env.example @@ -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 diff --git a/www/appv2/README.md b/www/appv2/README.md new file mode 100644 index 00000000..a4081ab2 --- /dev/null +++ b/www/appv2/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/www/appv2/dist/assets/index-BSIeQkMT.js b/www/appv2/dist/assets/index-BSIeQkMT.js new file mode 100644 index 00000000..56653885 --- /dev/null +++ b/www/appv2/dist/assets/index-BSIeQkMT.js @@ -0,0 +1,282 @@ +var gp=l=>{throw TypeError(l)};var cf=(l,i,s)=>i.has(l)||gp("Cannot "+s);var E=(l,i,s)=>(cf(l,i,"read from private field"),s?s.call(l):i.get(l)),oe=(l,i,s)=>i.has(l)?gp("Cannot add the same private member more than once"):i instanceof WeakSet?i.add(l):i.set(l,s),te=(l,i,s,u)=>(cf(l,i,"write to private field"),u?u.call(l,s):i.set(l,s),s),we=(l,i,s)=>(cf(l,i,"access private method"),s);var Nu=(l,i,s,u)=>({set _(o){te(l,i,o,s)},get _(){return E(l,i,u)}});(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))u(o);new MutationObserver(o=>{for(const d of o)if(d.type==="childList")for(const m of d.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&u(m)}).observe(document,{childList:!0,subtree:!0});function s(o){const d={};return o.integrity&&(d.integrity=o.integrity),o.referrerPolicy&&(d.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?d.credentials="include":o.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(o){if(o.ep)return;o.ep=!0;const d=s(o);fetch(o.href,d)}})();function zv(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var of={exports:{}},ks={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var xp;function ub(){if(xp)return ks;xp=1;var l=Symbol.for("react.transitional.element"),i=Symbol.for("react.fragment");function s(u,o,d){var m=null;if(d!==void 0&&(m=""+d),o.key!==void 0&&(m=""+o.key),"key"in o){d={};for(var p in o)p!=="key"&&(d[p]=o[p])}else d=o;return o=d.ref,{$$typeof:l,type:u,key:m,ref:o!==void 0?o:null,props:d}}return ks.Fragment=i,ks.jsx=s,ks.jsxs=s,ks}var bp;function cb(){return bp||(bp=1,of.exports=ub()),of.exports}var f=cb();const Ol=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,Ha=globalThis,Vs="10.46.0";function Bu(){return Kf(Ha),Ha}function Kf(l){const i=l.__SENTRY__=l.__SENTRY__||{};return i.version=i.version||Vs,i[Vs]=i[Vs]||{}}function Zf(l,i,s=Ha){const u=s.__SENTRY__=s.__SENTRY__||{},o=u[Vs]=u[Vs]||{};return o[l]||(o[l]=i())}const ob="Sentry Logger ",Sp={};function Uv(l){if(!("console"in Ha))return l();const i=Ha.console,s={},u=Object.keys(Sp);u.forEach(o=>{const d=Sp[o];s[o]=i[o],i[o]=d});try{return l()}finally{u.forEach(o=>{i[o]=s[o]})}}function fb(){Jf().enabled=!0}function db(){Jf().enabled=!1}function kv(){return Jf().enabled}function hb(...l){Ff("log",...l)}function mb(...l){Ff("warn",...l)}function yb(...l){Ff("error",...l)}function Ff(l,...i){Ol&&kv()&&Uv(()=>{Ha.console[l](`${ob}[${l}]:`,...i)})}function Jf(){return Ol?Zf("loggerSettings",()=>({enabled:!1})):{enabled:!1}}const Na={enable:fb,disable:db,isEnabled:kv,log:hb,warn:mb,error:yb},Lv=Object.prototype.toString;function pb(l){switch(Lv.call(l)){case"[object Error]":case"[object Exception]":case"[object DOMException]":case"[object WebAssembly.Exception]":return!0;default:return bb(l,Error)}}function vb(l,i){return Lv.call(l)===`[object ${i}]`}function gb(l){return vb(l,"Object")}function xb(l){return!!(l!=null&&l.then&&typeof l.then=="function")}function bb(l,i){try{return l instanceof i}catch{return!1}}function Sb(l,i,s){try{Object.defineProperty(l,i,{value:s,writable:!0,configurable:!0})}catch{Ol&&Na.log(`Failed to add non-enumerable property "${i}" to object`,l)}}let bi;function Qu(l){if(bi!==void 0)return bi?bi(l):l();const i=Symbol.for("__SENTRY_SAFE_RANDOM_ID_WRAPPER__"),s=Ha;return i in s&&typeof s[i]=="function"?(bi=s[i],bi(l)):(bi=null,l())}function _f(){return Qu(()=>Math.random())}function Eb(){return Qu(()=>Date.now())}function _b(l,i=0){return typeof l!="string"||i===0||l.length<=i?l:`${l.slice(0,i)}...`}function Nb(){const l=Ha;return l.crypto||l.msCrypto}let ff;function jb(){return _f()*16}function Ys(l=Nb()){try{if(l!=null&&l.randomUUID)return Qu(()=>l.randomUUID()).replace(/-/g,"")}catch{}return ff||(ff="10000000100040008000"+1e11),ff.replace(/[018]/g,i=>(i^(jb()&15)>>i/4).toString(16))}const Hv=1e3;function qv(){return Eb()/Hv}function wb(){const{performance:l}=Ha;if(!(l!=null&&l.now)||!l.timeOrigin)return qv;const i=l.timeOrigin;return()=>(i+Qu(()=>l.now()))/Hv}let Ep;function Tb(){return(Ep??(Ep=wb()))()}function Ab(l,i={}){if(i.user&&(!l.ipAddress&&i.user.ip_address&&(l.ipAddress=i.user.ip_address),!l.did&&!i.did&&(l.did=i.user.id||i.user.email||i.user.username)),l.timestamp=i.timestamp||Tb(),i.abnormal_mechanism&&(l.abnormal_mechanism=i.abnormal_mechanism),i.ignoreDuration&&(l.ignoreDuration=i.ignoreDuration),i.sid&&(l.sid=i.sid.length===32?i.sid:Ys()),i.init!==void 0&&(l.init=i.init),!l.did&&i.did&&(l.did=`${i.did}`),typeof i.started=="number"&&(l.started=i.started),l.ignoreDuration)l.duration=void 0;else if(typeof i.duration=="number")l.duration=i.duration;else{const s=l.timestamp-l.started;l.duration=s>=0?s:0}i.release&&(l.release=i.release),i.environment&&(l.environment=i.environment),!l.ipAddress&&i.ipAddress&&(l.ipAddress=i.ipAddress),!l.userAgent&&i.userAgent&&(l.userAgent=i.userAgent),typeof i.errors=="number"&&(l.errors=i.errors),i.status&&(l.status=i.status)}function Bv(l,i,s=2){if(!i||typeof i!="object"||s<=0)return i;if(l&&Object.keys(i).length===0)return l;const u={...l};for(const o in i)Object.prototype.hasOwnProperty.call(i,o)&&(u[o]=Bv(u[o],i[o],s-1));return u}function _p(){return Ys()}const Nf="_sentrySpan";function Np(l,i){i?Sb(l,Nf,i):delete l[Nf]}function jp(l){return l[Nf]}const Rb=100;class $n{constructor(){this._notifyingListeners=!1,this._scopeListeners=[],this._eventProcessors=[],this._breadcrumbs=[],this._attachments=[],this._user={},this._tags={},this._attributes={},this._extra={},this._contexts={},this._sdkProcessingMetadata={},this._propagationContext={traceId:_p(),sampleRand:_f()}}clone(){const i=new $n;return i._breadcrumbs=[...this._breadcrumbs],i._tags={...this._tags},i._attributes={...this._attributes},i._extra={...this._extra},i._contexts={...this._contexts},this._contexts.flags&&(i._contexts.flags={values:[...this._contexts.flags.values]}),i._user=this._user,i._level=this._level,i._session=this._session,i._transactionName=this._transactionName,i._fingerprint=this._fingerprint,i._eventProcessors=[...this._eventProcessors],i._attachments=[...this._attachments],i._sdkProcessingMetadata={...this._sdkProcessingMetadata},i._propagationContext={...this._propagationContext},i._client=this._client,i._lastEventId=this._lastEventId,i._conversationId=this._conversationId,Np(i,jp(this)),i}setClient(i){this._client=i}setLastEventId(i){this._lastEventId=i}getClient(){return this._client}lastEventId(){return this._lastEventId}addScopeListener(i){this._scopeListeners.push(i)}addEventProcessor(i){return this._eventProcessors.push(i),this}setUser(i){return this._user=i||{email:void 0,id:void 0,ip_address:void 0,username:void 0},this._session&&Ab(this._session,{user:i}),this._notifyScopeListeners(),this}getUser(){return this._user}setConversationId(i){return this._conversationId=i||void 0,this._notifyScopeListeners(),this}setTags(i){return this._tags={...this._tags,...i},this._notifyScopeListeners(),this}setTag(i,s){return this.setTags({[i]:s})}setAttributes(i){return this._attributes={...this._attributes,...i},this._notifyScopeListeners(),this}setAttribute(i,s){return this.setAttributes({[i]:s})}removeAttribute(i){return i in this._attributes&&(delete this._attributes[i],this._notifyScopeListeners()),this}setExtras(i){return this._extra={...this._extra,...i},this._notifyScopeListeners(),this}setExtra(i,s){return this._extra={...this._extra,[i]:s},this._notifyScopeListeners(),this}setFingerprint(i){return this._fingerprint=i,this._notifyScopeListeners(),this}setLevel(i){return this._level=i,this._notifyScopeListeners(),this}setTransactionName(i){return this._transactionName=i,this._notifyScopeListeners(),this}setContext(i,s){return s===null?delete this._contexts[i]:this._contexts[i]=s,this._notifyScopeListeners(),this}setSession(i){return i?this._session=i:delete this._session,this._notifyScopeListeners(),this}getSession(){return this._session}update(i){if(!i)return this;const s=typeof i=="function"?i(this):i,u=s instanceof $n?s.getScopeData():gb(s)?i:void 0,{tags:o,attributes:d,extra:m,user:p,contexts:x,level:v,fingerprint:b=[],propagationContext:g,conversationId:N}=u||{};return this._tags={...this._tags,...o},this._attributes={...this._attributes,...d},this._extra={...this._extra,...m},this._contexts={...this._contexts,...x},p&&Object.keys(p).length&&(this._user=p),v&&(this._level=v),b.length&&(this._fingerprint=b),g&&(this._propagationContext=g),N&&(this._conversationId=N),this}clear(){return this._breadcrumbs=[],this._tags={},this._attributes={},this._extra={},this._user={},this._contexts={},this._level=void 0,this._transactionName=void 0,this._fingerprint=void 0,this._session=void 0,this._conversationId=void 0,Np(this,void 0),this._attachments=[],this.setPropagationContext({traceId:_p(),sampleRand:_f()}),this._notifyScopeListeners(),this}addBreadcrumb(i,s){var d;const u=typeof s=="number"?s:Rb;if(u<=0)return this;const o={timestamp:qv(),...i,message:i.message?_b(i.message,2048):i.message};return this._breadcrumbs.push(o),this._breadcrumbs.length>u&&(this._breadcrumbs=this._breadcrumbs.slice(-u),(d=this._client)==null||d.recordDroppedEvent("buffer_overflow","log_item")),this._notifyScopeListeners(),this}getLastBreadcrumb(){return this._breadcrumbs[this._breadcrumbs.length-1]}clearBreadcrumbs(){return this._breadcrumbs=[],this._notifyScopeListeners(),this}addAttachment(i){return this._attachments.push(i),this}clearAttachments(){return this._attachments=[],this}getScopeData(){return{breadcrumbs:this._breadcrumbs,attachments:this._attachments,contexts:this._contexts,tags:this._tags,attributes:this._attributes,extra:this._extra,user:this._user,level:this._level,fingerprint:this._fingerprint||[],eventProcessors:this._eventProcessors,propagationContext:this._propagationContext,sdkProcessingMetadata:this._sdkProcessingMetadata,transactionName:this._transactionName,span:jp(this),conversationId:this._conversationId}}setSDKProcessingMetadata(i){return this._sdkProcessingMetadata=Bv(this._sdkProcessingMetadata,i,2),this}setPropagationContext(i){return this._propagationContext=i,this}getPropagationContext(){return this._propagationContext}captureException(i,s){const u=(s==null?void 0:s.event_id)||Ys();if(!this._client)return Ol&&Na.warn("No client configured on scope - will not capture exception!"),u;const o=new Error("Sentry syntheticException");return this._client.captureException(i,{originalException:i,syntheticException:o,...s,event_id:u},this),u}captureMessage(i,s,u){const o=(u==null?void 0:u.event_id)||Ys();if(!this._client)return Ol&&Na.warn("No client configured on scope - will not capture message!"),o;const d=(u==null?void 0:u.syntheticException)??new Error(i);return this._client.captureMessage(i,s,{originalException:i,syntheticException:d,...u,event_id:o},this),o}captureEvent(i,s){const u=i.event_id||(s==null?void 0:s.event_id)||Ys();return this._client?(this._client.captureEvent(i,{...s,event_id:u},this),u):(Ol&&Na.warn("No client configured on scope - will not capture event!"),u)}_notifyScopeListeners(){this._notifyingListeners||(this._notifyingListeners=!0,this._scopeListeners.forEach(i=>{i(this)}),this._notifyingListeners=!1)}}function Cb(){return Zf("defaultCurrentScope",()=>new $n)}function Ob(){return Zf("defaultIsolationScope",()=>new $n)}const wp=l=>l instanceof Promise&&!l[Qv],Qv=Symbol("chained PromiseLike"),Mb=(l,i,s)=>{const u=l.then(o=>(i(o),o),o=>{throw s(o),o});return wp(u)&&wp(l)?u:Db(l,u)},Db=(l,i)=>{let s=!1;for(const u in l){if(u in i)continue;s=!0;const o=l[u];typeof o=="function"?Object.defineProperty(i,u,{value:(...d)=>o.apply(l,d),enumerable:!0,configurable:!0,writable:!0}):i[u]=o}return s&&Object.assign(i,{[Qv]:!0}),i};class zb{constructor(i,s){let u;i?u=i:u=new $n;let o;s?o=s:o=new $n,this._stack=[{scope:u}],this._isolationScope=o}withScope(i){const s=this._pushScope();let u;try{u=i(s)}catch(o){throw this._popScope(),o}return xb(u)?Mb(u,()=>this._popScope(),()=>this._popScope()):(this._popScope(),u)}getClient(){return this.getStackTop().client}getScope(){return this.getStackTop().scope}getIsolationScope(){return this._isolationScope}getStackTop(){return this._stack[this._stack.length-1]}_pushScope(){const i=this.getScope().clone();return this._stack.push({client:this.getClient(),scope:i}),i}_popScope(){return this._stack.length<=1?!1:!!this._stack.pop()}}function Li(){const l=Bu(),i=Kf(l);return i.stack=i.stack||new zb(Cb(),Ob())}function Ub(l){return Li().withScope(l)}function kb(l,i){const s=Li();return s.withScope(()=>(s.getStackTop().scope=l,i(l)))}function Tp(l){return Li().withScope(()=>l(Li().getIsolationScope()))}function Lb(){return{withIsolationScope:Tp,withScope:Ub,withSetScope:kb,withSetIsolationScope:(l,i)=>Tp(i),getCurrentScope:()=>Li().getScope(),getIsolationScope:()=>Li().getIsolationScope()}}function $f(l){const i=Kf(l);return i.acs?i.acs:Lb()}function Wf(){const l=Bu();return $f(l).getCurrentScope()}function Hb(){const l=Bu();return $f(l).getIsolationScope()}function Vv(...l){const i=Bu(),s=$f(i);if(l.length===2){const[u,o]=l;return u?s.withSetScope(u,o):s.withScope(o)}return s.withScope(l[0])}function Yv(){return Wf().getClient()}const qb=/^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)((?:\[[:.%\w]+\]|[\w.-]+))(?::(\d+))?\/(.+)/;function Bb(l){return l==="http"||l==="https"}function Qb(l,i=!1){const{host:s,path:u,pass:o,port:d,projectId:m,protocol:p,publicKey:x}=l;return`${p}://${x}${i&&o?`:${o}`:""}@${s}${d?`:${d}`:""}/${u&&`${u}/`}${m}`}function Vb(l){const i=qb.exec(l);if(!i){Uv(()=>{console.error(`Invalid Sentry Dsn: ${l}`)});return}const[s,u,o="",d="",m="",p=""]=i.slice(1);let x="",v=p;const b=v.split("/");if(b.length>1&&(x=b.slice(0,-1).join("/"),v=b.pop()),v){const g=v.match(/^\d+/);g&&(v=g[0])}return Gv({host:d,pass:o,path:x,projectId:v,port:m,protocol:s,publicKey:u})}function Gv(l){return{protocol:l.protocol,publicKey:l.publicKey||"",pass:l.pass||"",host:l.host,port:l.port||"",path:l.path||"",projectId:l.projectId}}function Yb(l){if(!Ol)return!0;const{port:i,projectId:s,protocol:u}=l;return["protocol","publicKey","host","projectId"].find(m=>l[m]?!1:(Na.error(`Invalid Sentry Dsn: ${m} missing`),!0))?!1:s.match(/^\d+$/)?Bb(u)?i&&isNaN(parseInt(i,10))?(Na.error(`Invalid Sentry Dsn: Invalid port ${i}`),!1):!0:(Na.error(`Invalid Sentry Dsn: Invalid protocol ${u}`),!1):(Na.error(`Invalid Sentry Dsn: Invalid projectId ${s}`),!1)}function Gb(l){const i=typeof l=="string"?Vb(l):Gv(l);if(!(!i||!Yb(i)))return i}function Xb(l){if(l)return Kb(l)?{captureContext:l}:Fb(l)?{captureContext:l}:l}function Kb(l){return l instanceof $n||typeof l=="function"}const Zb=["user","level","extra","contexts","tags","fingerprint","propagationContext"];function Fb(l){return Object.keys(l).some(i=>Zb.includes(i))}function Jb(l,i){return Wf().captureException(l,Xb(i))}function $b(){return Hb().lastEventId()}function Wb(l){const i=l.protocol?`${l.protocol}:`:"",s=l.port?`:${l.port}`:"";return`${i}//${l.host}${s}${l.path?`/${l.path}`:""}/api/`}function Ib(l,i){const s=Gb(l);if(!s)return"";const u=`${Wb(s)}embed/error-page/`;let o=`dsn=${Qb(s)}`;for(const d in i)if(d!=="dsn"&&d!=="onClose")if(d==="user"){const m=i.user;if(!m)continue;m.name&&(o+=`&name=${encodeURIComponent(m.name)}`),m.email&&(o+=`&email=${encodeURIComponent(m.email)}`)}else o+=`&${encodeURIComponent(d)}=${encodeURIComponent(i[d])}`;return`${u}?${o}`}const ju=Ha,Ap=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__;function Rp(l={}){const i=ju.document,s=(i==null?void 0:i.head)||(i==null?void 0:i.body);if(!s){Ap&&Na.error("[showReportDialog] Global document not defined");return}const u=Wf(),o=Yv(),d=o==null?void 0:o.getDsn();if(!d){Ap&&Na.error("[showReportDialog] DSN not configured");return}const m={...l,user:{...u.getUser(),...l.user},eventId:l.eventId||$b()},p=ju.document.createElement("script");p.async=!0,p.crossOrigin="anonymous",p.src=Ib(d,m);const{onLoad:x,onClose:v}=m;if(x&&(p.onload=x),v){const b=g=>{if(g.data==="__sentry_reportdialog_closed__")try{v()}finally{ju.removeEventListener("message",b)}};ju.addEventListener("message",b)}s.appendChild(p)}var df={exports:{}},be={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Cp;function Pb(){if(Cp)return be;Cp=1;var l=Symbol.for("react.transitional.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),o=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),m=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),x=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),g=Symbol.for("react.activity"),N=Symbol.iterator;function T(w){return w===null||typeof w!="object"?null:(w=N&&w[N]||w["@@iterator"],typeof w=="function"?w:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},U=Object.assign,H={};function G(w,Y,I){this.props=w,this.context=Y,this.refs=H,this.updater=I||B}G.prototype.isReactComponent={},G.prototype.setState=function(w,Y){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,Y,"setState")},G.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function Q(){}Q.prototype=G.prototype;function K(w,Y,I){this.props=w,this.context=Y,this.refs=H,this.updater=I||B}var Z=K.prototype=new Q;Z.constructor=K,U(Z,G.prototype),Z.isPureReactComponent=!0;var ee=Array.isArray;function ve(){}var re={H:null,A:null,T:null,S:null},ce=Object.prototype.hasOwnProperty;function xe(w,Y,I){var W=I.ref;return{$$typeof:l,type:w,key:Y,ref:W!==void 0?W:null,props:I}}function Ae(w,Y){return xe(w.type,Y,w.props)}function ze(w){return typeof w=="object"&&w!==null&&w.$$typeof===l}function fe(w){var Y={"=":"=0",":":"=2"};return"$"+w.replace(/[=:]/g,function(I){return Y[I]})}var he=/\/+/g;function Ne(w,Y){return typeof w=="object"&&w!==null&&w.key!=null?fe(""+w.key):Y.toString(36)}function pe(w){switch(w.status){case"fulfilled":return w.value;case"rejected":throw w.reason;default:switch(typeof w.status=="string"?w.then(ve,ve):(w.status="pending",w.then(function(Y){w.status==="pending"&&(w.status="fulfilled",w.value=Y)},function(Y){w.status==="pending"&&(w.status="rejected",w.reason=Y)})),w.status){case"fulfilled":return w.value;case"rejected":throw w.reason}}throw w}function L(w,Y,I,W,ae){var ge=typeof w;(ge==="undefined"||ge==="boolean")&&(w=null);var Re=!1;if(w===null)Re=!0;else switch(ge){case"bigint":case"string":case"number":Re=!0;break;case"object":switch(w.$$typeof){case l:case i:Re=!0;break;case b:return Re=w._init,L(Re(w._payload),Y,I,W,ae)}}if(Re)return ae=ae(w),Re=W===""?"."+Ne(w,0):W,ee(ae)?(I="",Re!=null&&(I=Re.replace(he,"$&/")+"/"),L(ae,Y,I,"",function(dn){return dn})):ae!=null&&(ze(ae)&&(ae=Ae(ae,I+(ae.key==null||w&&w.key===ae.key?"":(""+ae.key).replace(he,"$&/")+"/")+Re)),Y.push(ae)),1;Re=0;var tt=W===""?".":W+":";if(ee(w))for(var Je=0;Je=17}function t1(l,i){const s=new WeakSet;function u(o,d){if(!s.has(o)){if(o.cause)return s.add(o),u(o.cause,d);o.cause=d}}u(l,i)}function a1(l,{componentStack:i},s){if(e1(A.version)&&pb(l)&&i){const u=new Error(l.message);u.name=`React ErrorBoundary ${l.name}`,u.stack=i,t1(l,u)}return Vv(u=>(u.setContext("react",{componentStack:i}),Jb(l,s)))}const n1=typeof __SENTRY_DEBUG__>"u"||__SENTRY_DEBUG__,hf={componentStack:null,error:null,eventId:null};class l1 extends A.Component{constructor(i){super(i),this.state=hf,this._openFallbackReportDialog=!0;const s=Yv();s&&i.showDialog&&(this._openFallbackReportDialog=!1,this._cleanupHook=s.on("afterSendEvent",u=>{!u.type&&this._lastEventId&&u.event_id===this._lastEventId&&Rp({...i.dialogOptions,eventId:this._lastEventId})}))}componentDidCatch(i,s){const{componentStack:u}=s,{beforeCapture:o,onError:d,showDialog:m,dialogOptions:p}=this.props;Vv(x=>{o&&o(x,i,u);const v=this.props.handled!=null?this.props.handled:!!this.props.fallback,b=a1(i,s,{mechanism:{handled:v,type:"auto.function.react.error_boundary"}});d&&d(i,u,b),m&&(this._lastEventId=b,this._openFallbackReportDialog&&Rp({...p,eventId:b})),this.setState({error:i,componentStack:u,eventId:b})})}componentDidMount(){const{onMount:i}=this.props;i&&i()}componentWillUnmount(){const{error:i,componentStack:s,eventId:u}=this.state,{onUnmount:o}=this.props;o&&(this.state===hf?o(null,null,null):o(i,s,u)),this._cleanupHook&&(this._cleanupHook(),this._cleanupHook=void 0)}resetErrorBoundary(){const{onReset:i}=this.props,{error:s,componentStack:u,eventId:o}=this.state;i&&i(s,u,o),this.setState(hf)}render(){const{fallback:i,children:s}=this.props,u=this.state;if(u.componentStack===null)return typeof s=="function"?s():s;const o=typeof i=="function"?A.createElement(i,{error:u.error,componentStack:u.componentStack,resetError:()=>this.resetErrorBoundary(),eventId:u.eventId}):i;return A.isValidElement(o)?o:(i&&n1&&Na.warn("fallback did not produce a valid ReactElement"),null)}}var mf={exports:{}},Ls={},yf={exports:{}},pf={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Mp;function i1(){return Mp||(Mp=1,(function(l){function i(L,F){var ie=L.length;L.push(F);e:for(;0>>1,me=L[Se];if(0>>1;Seo(I,ie))Wo(ae,I)?(L[Se]=ae,L[W]=ie,Se=W):(L[Se]=I,L[Y]=ie,Se=Y);else if(Wo(ae,ie))L[Se]=ae,L[W]=ie,Se=W;else break e}}return F}function o(L,F){var ie=L.sortIndex-F.sortIndex;return ie!==0?ie:L.id-F.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;l.unstable_now=function(){return d.now()}}else{var m=Date,p=m.now();l.unstable_now=function(){return m.now()-p}}var x=[],v=[],b=1,g=null,N=3,T=!1,B=!1,U=!1,H=!1,G=typeof setTimeout=="function"?setTimeout:null,Q=typeof clearTimeout=="function"?clearTimeout:null,K=typeof setImmediate<"u"?setImmediate:null;function Z(L){for(var F=s(v);F!==null;){if(F.callback===null)u(v);else if(F.startTime<=L)u(v),F.sortIndex=F.expirationTime,i(x,F);else break;F=s(v)}}function ee(L){if(U=!1,Z(L),!B)if(s(x)!==null)B=!0,ve||(ve=!0,fe());else{var F=s(v);F!==null&&pe(ee,F.startTime-L)}}var ve=!1,re=-1,ce=5,xe=-1;function Ae(){return H?!0:!(l.unstable_now()-xeL&&Ae());){var Se=g.callback;if(typeof Se=="function"){g.callback=null,N=g.priorityLevel;var me=Se(g.expirationTime<=L);if(L=l.unstable_now(),typeof me=="function"){g.callback=me,Z(L),F=!0;break t}g===s(x)&&u(x),Z(L)}else u(x);g=s(x)}if(g!==null)F=!0;else{var w=s(v);w!==null&&pe(ee,w.startTime-L),F=!1}}break e}finally{g=null,N=ie,T=!1}F=void 0}}finally{F?fe():ve=!1}}}var fe;if(typeof K=="function")fe=function(){K(ze)};else if(typeof MessageChannel<"u"){var he=new MessageChannel,Ne=he.port2;he.port1.onmessage=ze,fe=function(){Ne.postMessage(null)}}else fe=function(){G(ze,0)};function pe(L,F){re=G(function(){L(l.unstable_now())},F)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(L){L.callback=null},l.unstable_forceFrameRate=function(L){0>L||125Se?(L.sortIndex=ie,i(v,L),s(x)===null&&L===s(v)&&(U?(Q(re),re=-1):U=!0,pe(ee,ie-Se))):(L.sortIndex=me,i(x,L),B||T||(B=!0,ve||(ve=!0,fe()))),L},l.unstable_shouldYield=Ae,l.unstable_wrapCallback=function(L){var F=N;return function(){var ie=N;N=F;try{return L.apply(this,arguments)}finally{N=ie}}}})(pf)),pf}var Dp;function s1(){return Dp||(Dp=1,yf.exports=i1()),yf.exports}var vf={exports:{}},Tt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var zp;function r1(){if(zp)return Tt;zp=1;var l=If();function i(x){var v="https://react.dev/errors/"+x;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(i){console.error(i)}}return l(),vf.exports=r1(),vf.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var kp;function c1(){if(kp)return Ls;kp=1;var l=s1(),i=If(),s=u1();function u(e){var t="https://react.dev/errors/"+e;if(1me||(e.current=Se[me],Se[me]=null,me--)}function I(e,t){me++,Se[me]=e.current,e.current=t}var W=w(null),ae=w(null),ge=w(null),Re=w(null);function tt(e,t){switch(I(ge,t),I(ae,e),I(W,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Qy(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Qy(t),e=Vy(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Y(W),I(W,e)}function Je(){Y(W),Y(ae),Y(ge)}function dn(e){e.memoizedState!==null&&I(Re,e);var t=W.current,a=Vy(t,e.type);t!==a&&(I(ae,e),I(W,a))}function hn(e){ae.current===e&&(Y(W),Y(ae)),Re.current===e&&(Y(Re),Ms._currentValue=ie)}var Pn,lr;function Ta(e){if(Pn===void 0)try{throw Error()}catch(a){var t=a.stack.trim().match(/\n( *(at )?)/);Pn=t&&t[1]||"",lr=-1)":-1r||S[n]!==M[r]){var q=` +`+S[n].replace(" at new "," at ");return e.displayName&&q.includes("")&&(q=q.replace("",e.displayName)),q}while(1<=n&&0<=r);break}}}finally{Yi=!1,Error.prepareStackTrace=a}return(a=e?e.displayName||e.name:"")?Ta(a):""}function ir(e,t){switch(e.tag){case 26:case 27:case 5:return Ta(e.type);case 16:return Ta("Lazy");case 13:return e.child!==t&&t!==null?Ta("Suspense Fallback"):Ta("Suspense");case 19:return Ta("SuspenseList");case 0:case 15:return zl(e.type,!1);case 11:return zl(e.type.render,!1);case 1:return zl(e.type,!0);case 31:return Ta("Activity");default:return""}}function sr(e){try{var t="",a=null;do t+=ir(e,a),a=e,e=e.return;while(e);return t}catch(n){return` +Error generating stack: `+n.message+` +`+n.stack}}var Gi=Object.prototype.hasOwnProperty,rr=l.unstable_scheduleCallback,Ul=l.unstable_cancelCallback,_=l.unstable_shouldYield,C=l.unstable_requestPaint,D=l.unstable_now,P=l.unstable_getCurrentPriorityLevel,J=l.unstable_ImmediatePriority,$=l.unstable_UserBlockingPriority,le=l.unstable_NormalPriority,Ee=l.unstable_LowPriority,He=l.unstable_IdlePriority,Rt=l.log,aa=l.unstable_setDisableYieldValue,ct=null,Ct=null;function na(e){if(typeof Rt=="function"&&aa(e),Ct&&typeof Ct.setStrictMode=="function")try{Ct.setStrictMode(ct,e)}catch{}}var St=Math.clz32?Math.clz32:Fu,Zu=Math.log,ur=Math.LN2;function Fu(e){return e>>>=0,e===0?32:31-(Zu(e)/ur|0)|0}var el=256,cr=262144,or=4194304;function tl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function fr(e,t,a){var n=e.pendingLanes;if(n===0)return 0;var r=0,c=e.suspendedLanes,h=e.pingedLanes;e=e.warmLanes;var y=n&134217727;return y!==0?(n=y&~c,n!==0?r=tl(n):(h&=y,h!==0?r=tl(h):a||(a=y&~e,a!==0&&(r=tl(a))))):(y=n&~c,y!==0?r=tl(y):h!==0?r=tl(h):a||(a=n&~e,a!==0&&(r=tl(a)))),r===0?0:t!==0&&t!==r&&(t&c)===0&&(c=r&-r,a=t&-t,c>=a||c===32&&(a&4194048)!==0)?t:r}function Xi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Jg(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ed(){var e=or;return or<<=1,(or&62914560)===0&&(or=4194304),e}function Ju(e){for(var t=[],a=0;31>a;a++)t.push(e);return t}function Ki(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function $g(e,t,a,n,r,c){var h=e.pendingLanes;e.pendingLanes=a,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=a,e.entangledLanes&=a,e.errorRecoveryDisabledLanes&=a,e.shellSuspendCounter=0;var y=e.entanglements,S=e.expirationTimes,M=e.hiddenUpdates;for(a=h&~a;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var a0=/[\n"\\]/g;function ia(e){return e.replace(a0,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function tc(e,t,a,n,r,c,h,y){e.name="",h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"?e.type=h:e.removeAttribute("type"),t!=null?h==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+la(t)):e.value!==""+la(t)&&(e.value=""+la(t)):h!=="submit"&&h!=="reset"||e.removeAttribute("value"),t!=null?ac(e,h,la(t)):a!=null?ac(e,h,la(a)):n!=null&&e.removeAttribute("value"),r==null&&c!=null&&(e.defaultChecked=!!c),r!=null&&(e.checked=r&&typeof r!="function"&&typeof r!="symbol"),y!=null&&typeof y!="function"&&typeof y!="symbol"&&typeof y!="boolean"?e.name=""+la(y):e.removeAttribute("name")}function Ud(e,t,a,n,r,c,h,y){if(c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(e.type=c),t!=null||a!=null){if(!(c!=="submit"&&c!=="reset"||t!=null)){ec(e);return}a=a!=null?""+la(a):"",t=t!=null?""+la(t):a,y||t===e.value||(e.value=t),e.defaultValue=t}n=n??r,n=typeof n!="function"&&typeof n!="symbol"&&!!n,e.checked=y?e.checked:!!n,e.defaultChecked=!!n,h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(e.name=h),ec(e)}function ac(e,t,a){t==="number"&&mr(e.ownerDocument)===e||e.defaultValue===""+a||(e.defaultValue=""+a)}function Ql(e,t,a,n){if(e=e.options,t){t={};for(var r=0;r"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),rc=!1;if(Va)try{var $i={};Object.defineProperty($i,"passive",{get:function(){rc=!0}}),window.addEventListener("test",$i,$i),window.removeEventListener("test",$i,$i)}catch{rc=!1}var yn=null,uc=null,pr=null;function Vd(){if(pr)return pr;var e,t=uc,a=t.length,n,r="value"in yn?yn.value:yn.textContent,c=r.length;for(e=0;e=Pi),Fd=" ",Jd=!1;function $d(e,t){switch(e){case"keyup":return C0.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Wd(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Xl=!1;function M0(e,t){switch(e){case"compositionend":return Wd(t);case"keypress":return t.which!==32?null:(Jd=!0,Fd);case"textInput":return e=t.data,e===Fd&&Jd?null:e;default:return null}}function D0(e,t){if(Xl)return e==="compositionend"||!hc&&$d(e,t)?(e=Vd(),pr=uc=yn=null,Xl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-e};e=n}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=ih(a)}}function rh(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?rh(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function uh(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=mr(e.document);t instanceof e.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)e=t.contentWindow;else break;t=mr(e.document)}return t}function pc(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var Q0=Va&&"documentMode"in document&&11>=document.documentMode,Kl=null,vc=null,ns=null,gc=!1;function ch(e,t,a){var n=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;gc||Kl==null||Kl!==mr(n)||(n=Kl,"selectionStart"in n&&pc(n)?n={start:n.selectionStart,end:n.selectionEnd}:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection(),n={anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}),ns&&as(ns,n)||(ns=n,n=cu(vc,"onSelect"),0>=h,r-=h,Aa=1<<32-St(t)+r|a<je?(De=ue,ue=null):De=ue.sibling;var Be=z(R,ue,O[je],V);if(Be===null){ue===null&&(ue=De);break}e&&ue&&Be.alternate===null&&t(R,ue),j=c(Be,j,je),qe===null?de=Be:qe.sibling=Be,qe=Be,ue=De}if(je===O.length)return a(R,ue),Ue&&Ga(R,je),de;if(ue===null){for(;jeje?(De=ue,ue=null):De=ue.sibling;var Ln=z(R,ue,Be.value,V);if(Ln===null){ue===null&&(ue=De);break}e&&ue&&Ln.alternate===null&&t(R,ue),j=c(Ln,j,je),qe===null?de=Ln:qe.sibling=Ln,qe=Ln,ue=De}if(Be.done)return a(R,ue),Ue&&Ga(R,je),de;if(ue===null){for(;!Be.done;je++,Be=O.next())Be=X(R,Be.value,V),Be!==null&&(j=c(Be,j,je),qe===null?de=Be:qe.sibling=Be,qe=Be);return Ue&&Ga(R,je),de}for(ue=n(ue);!Be.done;je++,Be=O.next())Be=k(ue,R,je,Be.value,V),Be!==null&&(e&&Be.alternate!==null&&ue.delete(Be.key===null?je:Be.key),j=c(Be,j,je),qe===null?de=Be:qe.sibling=Be,qe=Be);return e&&ue.forEach(function(rb){return t(R,rb)}),Ue&&Ga(R,je),de}function Ze(R,j,O,V){if(typeof O=="object"&&O!==null&&O.type===U&&O.key===null&&(O=O.props.children),typeof O=="object"&&O!==null){switch(O.$$typeof){case T:e:{for(var de=O.key;j!==null;){if(j.key===de){if(de=O.type,de===U){if(j.tag===7){a(R,j.sibling),V=r(j,O.props.children),V.return=R,R=V;break e}}else if(j.elementType===de||typeof de=="object"&&de!==null&&de.$$typeof===ce&&dl(de)===j.type){a(R,j.sibling),V=r(j,O.props),cs(V,O),V.return=R,R=V;break e}a(R,j);break}else t(R,j);j=j.sibling}O.type===U?(V=rl(O.props.children,R.mode,V,O.key),V.return=R,R=V):(V=wr(O.type,O.key,O.props,null,R.mode,V),cs(V,O),V.return=R,R=V)}return h(R);case B:e:{for(de=O.key;j!==null;){if(j.key===de)if(j.tag===4&&j.stateNode.containerInfo===O.containerInfo&&j.stateNode.implementation===O.implementation){a(R,j.sibling),V=r(j,O.children||[]),V.return=R,R=V;break e}else{a(R,j);break}else t(R,j);j=j.sibling}V=jc(O,R.mode,V),V.return=R,R=V}return h(R);case ce:return O=dl(O),Ze(R,j,O,V)}if(pe(O))return ne(R,j,O,V);if(fe(O)){if(de=fe(O),typeof de!="function")throw Error(u(150));return O=de.call(O),ye(R,j,O,V)}if(typeof O.then=="function")return Ze(R,j,Dr(O),V);if(O.$$typeof===K)return Ze(R,j,Rr(R,O),V);zr(R,O)}return typeof O=="string"&&O!==""||typeof O=="number"||typeof O=="bigint"?(O=""+O,j!==null&&j.tag===6?(a(R,j.sibling),V=r(j,O),V.return=R,R=V):(a(R,j),V=Nc(O,R.mode,V),V.return=R,R=V),h(R)):a(R,j)}return function(R,j,O,V){try{us=0;var de=Ze(R,j,O,V);return ni=null,de}catch(ue){if(ue===ai||ue===Or)throw ue;var qe=Ft(29,ue,null,R.mode);return qe.lanes=V,qe.return=R,qe}finally{}}}var ml=Mh(!0),Dh=Mh(!1),bn=!1;function Lc(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Hc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Sn(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function En(e,t,a){var n=e.updateQueue;if(n===null)return null;if(n=n.shared,(Qe&2)!==0){var r=n.pending;return r===null?t.next=t:(t.next=r.next,r.next=t),n.pending=t,t=jr(e),ph(e,null,a),t}return Nr(e,n,t,a),jr(e)}function os(e,t,a){if(t=t.updateQueue,t!==null&&(t=t.shared,(a&4194048)!==0)){var n=t.lanes;n&=e.pendingLanes,a|=n,t.lanes=a,Nd(e,a)}}function qc(e,t){var a=e.updateQueue,n=e.alternate;if(n!==null&&(n=n.updateQueue,a===n)){var r=null,c=null;if(a=a.firstBaseUpdate,a!==null){do{var h={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};c===null?r=c=h:c=c.next=h,a=a.next}while(a!==null);c===null?r=c=t:c=c.next=t}else r=c=t;a={baseState:n.baseState,firstBaseUpdate:r,lastBaseUpdate:c,shared:n.shared,callbacks:n.callbacks},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=t:e.next=t,a.lastBaseUpdate=t}var Bc=!1;function fs(){if(Bc){var e=ti;if(e!==null)throw e}}function ds(e,t,a,n){Bc=!1;var r=e.updateQueue;bn=!1;var c=r.firstBaseUpdate,h=r.lastBaseUpdate,y=r.shared.pending;if(y!==null){r.shared.pending=null;var S=y,M=S.next;S.next=null,h===null?c=M:h.next=M,h=S;var q=e.alternate;q!==null&&(q=q.updateQueue,y=q.lastBaseUpdate,y!==h&&(y===null?q.firstBaseUpdate=M:y.next=M,q.lastBaseUpdate=S))}if(c!==null){var X=r.baseState;h=0,q=M=S=null,y=c;do{var z=y.lane&-536870913,k=z!==y.lane;if(k?(Me&z)===z:(n&z)===z){z!==0&&z===ei&&(Bc=!0),q!==null&&(q=q.next={lane:0,tag:y.tag,payload:y.payload,callback:null,next:null});e:{var ne=e,ye=y;z=t;var Ze=a;switch(ye.tag){case 1:if(ne=ye.payload,typeof ne=="function"){X=ne.call(Ze,X,z);break e}X=ne;break e;case 3:ne.flags=ne.flags&-65537|128;case 0:if(ne=ye.payload,z=typeof ne=="function"?ne.call(Ze,X,z):ne,z==null)break e;X=g({},X,z);break e;case 2:bn=!0}}z=y.callback,z!==null&&(e.flags|=64,k&&(e.flags|=8192),k=r.callbacks,k===null?r.callbacks=[z]:k.push(z))}else k={lane:z,tag:y.tag,payload:y.payload,callback:y.callback,next:null},q===null?(M=q=k,S=X):q=q.next=k,h|=z;if(y=y.next,y===null){if(y=r.shared.pending,y===null)break;k=y,y=k.next,k.next=null,r.lastBaseUpdate=k,r.shared.pending=null}}while(!0);q===null&&(S=X),r.baseState=S,r.firstBaseUpdate=M,r.lastBaseUpdate=q,c===null&&(r.shared.lanes=0),Tn|=h,e.lanes=h,e.memoizedState=X}}function zh(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function Uh(e,t){var a=e.callbacks;if(a!==null)for(e.callbacks=null,e=0;ec?c:8;var h=L.T,y={};L.T=y,io(e,!1,t,a);try{var S=r(),M=L.S;if(M!==null&&M(y,S),S!==null&&typeof S=="object"&&typeof S.then=="function"){var q=$0(S,n);ys(e,t,q,Pt(e))}else ys(e,t,n,Pt(e))}catch(X){ys(e,t,{then:function(){},status:"rejected",reason:X},Pt())}finally{F.p=c,h!==null&&y.types!==null&&(h.types=y.types),L.T=h}}function ax(){}function no(e,t,a,n){if(e.tag!==5)throw Error(u(476));var r=hm(e).queue;dm(e,r,t,ie,a===null?ax:function(){return mm(e),a(n)})}function hm(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ie,baseState:ie,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fa,lastRenderedState:ie},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fa,lastRenderedState:a},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function mm(e){var t=hm(e);t.next===null&&(t=e.alternate.memoizedState),ys(e,t.next.queue,{},Pt())}function lo(){return Nt(Ms)}function ym(){return rt().memoizedState}function pm(){return rt().memoizedState}function nx(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var a=Pt();e=Sn(a);var n=En(t,e,a);n!==null&&(Vt(n,t,a),os(n,t,a)),t={cache:Dc()},e.payload=t;return}t=t.return}}function lx(e,t,a){var n=Pt();a={lane:n,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},Gr(e)?gm(t,a):(a=Ec(e,t,a,n),a!==null&&(Vt(a,e,n),xm(a,t,n)))}function vm(e,t,a){var n=Pt();ys(e,t,a,n)}function ys(e,t,a,n){var r={lane:n,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(Gr(e))gm(t,r);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var h=t.lastRenderedState,y=c(h,a);if(r.hasEagerState=!0,r.eagerState=y,Zt(y,h))return Nr(e,t,r,0),Fe===null&&_r(),!1}catch{}finally{}if(a=Ec(e,t,r,n),a!==null)return Vt(a,e,n),xm(a,t,n),!0}return!1}function io(e,t,a,n){if(n={lane:2,revertLane:Ho(),gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Gr(e)){if(t)throw Error(u(479))}else t=Ec(e,a,n,2),t!==null&&Vt(t,e,2)}function Gr(e){var t=e.alternate;return e===_e||t!==null&&t===_e}function gm(e,t){ii=Lr=!0;var a=e.pending;a===null?t.next=t:(t.next=a.next,a.next=t),e.pending=t}function xm(e,t,a){if((a&4194048)!==0){var n=t.lanes;n&=e.pendingLanes,a|=n,t.lanes=a,Nd(e,a)}}var ps={readContext:Nt,use:Br,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useLayoutEffect:nt,useInsertionEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useSyncExternalStore:nt,useId:nt,useHostTransitionStatus:nt,useFormState:nt,useActionState:nt,useOptimistic:nt,useMemoCache:nt,useCacheRefresh:nt};ps.useEffectEvent=nt;var bm={readContext:Nt,use:Br,useCallback:function(e,t){return Ot().memoizedState=[e,t===void 0?null:t],e},useContext:Nt,useEffect:nm,useImperativeHandle:function(e,t,a){a=a!=null?a.concat([e]):null,Vr(4194308,4,rm.bind(null,t,e),a)},useLayoutEffect:function(e,t){return Vr(4194308,4,e,t)},useInsertionEffect:function(e,t){Vr(4,2,e,t)},useMemo:function(e,t){var a=Ot();t=t===void 0?null:t;var n=e();if(yl){na(!0);try{e()}finally{na(!1)}}return a.memoizedState=[n,t],n},useReducer:function(e,t,a){var n=Ot();if(a!==void 0){var r=a(t);if(yl){na(!0);try{a(t)}finally{na(!1)}}}else r=t;return n.memoizedState=n.baseState=r,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:r},n.queue=e,e=e.dispatch=lx.bind(null,_e,e),[n.memoizedState,e]},useRef:function(e){var t=Ot();return e={current:e},t.memoizedState=e},useState:function(e){e=Ic(e);var t=e.queue,a=vm.bind(null,_e,t);return t.dispatch=a,[e.memoizedState,a]},useDebugValue:to,useDeferredValue:function(e,t){var a=Ot();return ao(a,e,t)},useTransition:function(){var e=Ic(!1);return e=dm.bind(null,_e,e.queue,!0,!1),Ot().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,a){var n=_e,r=Ot();if(Ue){if(a===void 0)throw Error(u(407));a=a()}else{if(a=t(),Fe===null)throw Error(u(349));(Me&127)!==0||Qh(n,t,a)}r.memoizedState=a;var c={value:a,getSnapshot:t};return r.queue=c,nm(Yh.bind(null,n,c,e),[e]),n.flags|=2048,ri(9,{destroy:void 0},Vh.bind(null,n,c,a,t),null),a},useId:function(){var e=Ot(),t=Fe.identifierPrefix;if(Ue){var a=Ra,n=Aa;a=(n&~(1<<32-St(n)-1)).toString(32)+a,t="_"+t+"R_"+a,a=Hr++,0<\/script>",c=c.removeChild(c.firstChild);break;case"select":c=typeof n.is=="string"?h.createElement("select",{is:n.is}):h.createElement("select"),n.multiple?c.multiple=!0:n.size&&(c.size=n.size);break;default:c=typeof n.is=="string"?h.createElement(r,{is:n.is}):h.createElement(r)}}c[Et]=t,c[kt]=n;e:for(h=t.child;h!==null;){if(h.tag===5||h.tag===6)c.appendChild(h.stateNode);else if(h.tag!==4&&h.tag!==27&&h.child!==null){h.child.return=h,h=h.child;continue}if(h===t)break e;for(;h.sibling===null;){if(h.return===null||h.return===t)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}t.stateNode=c;e:switch(wt(c,r,n),r){case"button":case"input":case"select":case"textarea":n=!!n.autoFocus;break e;case"img":n=!0;break e;default:n=!1}n&&$a(t)}}return We(t),bo(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,a),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==n&&$a(t);else{if(typeof n!="string"&&t.stateNode===null)throw Error(u(166));if(e=ge.current,Il(t)){if(e=t.stateNode,a=t.memoizedProps,n=null,r=_t,r!==null)switch(r.tag){case 27:case 5:n=r.memoizedProps}e[Et]=t,e=!!(e.nodeValue===a||n!==null&&n.suppressHydrationWarning===!0||qy(e.nodeValue,a)),e||gn(t,!0)}else e=ou(e).createTextNode(n),e[Et]=t,t.stateNode=e}return We(t),null;case 31:if(a=t.memoizedState,e===null||e.memoizedState!==null){if(n=Il(t),a!==null){if(e===null){if(!n)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[Et]=t}else ul(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;We(t),e=!1}else a=Rc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),e=!0;if(!e)return t.flags&256?($t(t),t):($t(t),null);if((t.flags&128)!==0)throw Error(u(558))}return We(t),null;case 13:if(n=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(r=Il(t),n!==null&&n.dehydrated!==null){if(e===null){if(!r)throw Error(u(318));if(r=t.memoizedState,r=r!==null?r.dehydrated:null,!r)throw Error(u(317));r[Et]=t}else ul(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;We(t),r=!1}else r=Rc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=r),r=!0;if(!r)return t.flags&256?($t(t),t):($t(t),null)}return $t(t),(t.flags&128)!==0?(t.lanes=a,t):(a=n!==null,e=e!==null&&e.memoizedState!==null,a&&(n=t.child,r=null,n.alternate!==null&&n.alternate.memoizedState!==null&&n.alternate.memoizedState.cachePool!==null&&(r=n.alternate.memoizedState.cachePool.pool),c=null,n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(c=n.memoizedState.cachePool.pool),c!==r&&(n.flags|=2048)),a!==e&&a&&(t.child.flags|=8192),Jr(t,t.updateQueue),We(t),null);case 4:return Je(),e===null&&Vo(t.stateNode.containerInfo),We(t),null;case 10:return Ka(t.type),We(t),null;case 19:if(Y(st),n=t.memoizedState,n===null)return We(t),null;if(r=(t.flags&128)!==0,c=n.rendering,c===null)if(r)gs(n,!1);else{if(lt!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(c=kr(e),c!==null){for(t.flags|=128,gs(n,!1),e=c.updateQueue,t.updateQueue=e,Jr(t,e),t.subtreeFlags=0,e=a,a=t.child;a!==null;)vh(a,e),a=a.sibling;return I(st,st.current&1|2),Ue&&Ga(t,n.treeForkCount),t.child}e=e.sibling}n.tail!==null&&D()>eu&&(t.flags|=128,r=!0,gs(n,!1),t.lanes=4194304)}else{if(!r)if(e=kr(c),e!==null){if(t.flags|=128,r=!0,e=e.updateQueue,t.updateQueue=e,Jr(t,e),gs(n,!0),n.tail===null&&n.tailMode==="hidden"&&!c.alternate&&!Ue)return We(t),null}else 2*D()-n.renderingStartTime>eu&&a!==536870912&&(t.flags|=128,r=!0,gs(n,!1),t.lanes=4194304);n.isBackwards?(c.sibling=t.child,t.child=c):(e=n.last,e!==null?e.sibling=c:t.child=c,n.last=c)}return n.tail!==null?(e=n.tail,n.rendering=e,n.tail=e.sibling,n.renderingStartTime=D(),e.sibling=null,a=st.current,I(st,r?a&1|2:a&1),Ue&&Ga(t,n.treeForkCount),e):(We(t),null);case 22:case 23:return $t(t),Vc(),n=t.memoizedState!==null,e!==null?e.memoizedState!==null!==n&&(t.flags|=8192):n&&(t.flags|=8192),n?(a&536870912)!==0&&(t.flags&128)===0&&(We(t),t.subtreeFlags&6&&(t.flags|=8192)):We(t),a=t.updateQueue,a!==null&&Jr(t,a.retryQueue),a=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(a=e.memoizedState.cachePool.pool),n=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(n=t.memoizedState.cachePool.pool),n!==a&&(t.flags|=2048),e!==null&&Y(fl),null;case 24:return a=null,e!==null&&(a=e.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Ka(ot),We(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function cx(e,t){switch(Tc(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ka(ot),Je(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return hn(t),null;case 31:if(t.memoizedState!==null){if($t(t),t.alternate===null)throw Error(u(340));ul()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if($t(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));ul()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Y(st),null;case 4:return Je(),null;case 10:return Ka(t.type),null;case 22:case 23:return $t(t),Vc(),e!==null&&Y(fl),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ka(ot),null;case 25:return null;default:return null}}function Gm(e,t){switch(Tc(t),t.tag){case 3:Ka(ot),Je();break;case 26:case 27:case 5:hn(t);break;case 4:Je();break;case 31:t.memoizedState!==null&&$t(t);break;case 13:$t(t);break;case 19:Y(st);break;case 10:Ka(t.type);break;case 22:case 23:$t(t),Vc(),e!==null&&Y(fl);break;case 24:Ka(ot)}}function xs(e,t){try{var a=t.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var r=n.next;a=r;do{if((a.tag&e)===e){n=void 0;var c=a.create,h=a.inst;n=c(),h.destroy=n}a=a.next}while(a!==r)}}catch(y){Ge(t,t.return,y)}}function jn(e,t,a){try{var n=t.updateQueue,r=n!==null?n.lastEffect:null;if(r!==null){var c=r.next;n=c;do{if((n.tag&e)===e){var h=n.inst,y=h.destroy;if(y!==void 0){h.destroy=void 0,r=t;var S=a,M=y;try{M()}catch(q){Ge(r,S,q)}}}n=n.next}while(n!==c)}}catch(q){Ge(t,t.return,q)}}function Xm(e){var t=e.updateQueue;if(t!==null){var a=e.stateNode;try{Uh(t,a)}catch(n){Ge(e,e.return,n)}}}function Km(e,t,a){a.props=pl(e.type,e.memoizedProps),a.state=e.memoizedState;try{a.componentWillUnmount()}catch(n){Ge(e,t,n)}}function bs(e,t){try{var a=e.ref;if(a!==null){switch(e.tag){case 26:case 27:case 5:var n=e.stateNode;break;case 30:n=e.stateNode;break;default:n=e.stateNode}typeof a=="function"?e.refCleanup=a(n):a.current=n}}catch(r){Ge(e,t,r)}}function Ca(e,t){var a=e.ref,n=e.refCleanup;if(a!==null)if(typeof n=="function")try{n()}catch(r){Ge(e,t,r)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(r){Ge(e,t,r)}else a.current=null}function Zm(e){var t=e.type,a=e.memoizedProps,n=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":a.autoFocus&&n.focus();break e;case"img":a.src?n.src=a.src:a.srcSet&&(n.srcset=a.srcSet)}}catch(r){Ge(e,e.return,r)}}function So(e,t,a){try{var n=e.stateNode;Ox(n,e.type,a,t),n[kt]=t}catch(r){Ge(e,e.return,r)}}function Fm(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Mn(e.type)||e.tag===4}function Eo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Fm(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Mn(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function _o(e,t,a){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(e,t):(t=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,t.appendChild(e),a=a._reactRootContainer,a!=null||t.onclick!==null||(t.onclick=Qa));else if(n!==4&&(n===27&&Mn(e.type)&&(a=e.stateNode,t=null),e=e.child,e!==null))for(_o(e,t,a),e=e.sibling;e!==null;)_o(e,t,a),e=e.sibling}function $r(e,t,a){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?a.insertBefore(e,t):a.appendChild(e);else if(n!==4&&(n===27&&Mn(e.type)&&(a=e.stateNode),e=e.child,e!==null))for($r(e,t,a),e=e.sibling;e!==null;)$r(e,t,a),e=e.sibling}function Jm(e){var t=e.stateNode,a=e.memoizedProps;try{for(var n=e.type,r=t.attributes;r.length;)t.removeAttributeNode(r[0]);wt(t,n,a),t[Et]=e,t[kt]=a}catch(c){Ge(e,e.return,c)}}var Wa=!1,ht=!1,No=!1,$m=typeof WeakSet=="function"?WeakSet:Set,xt=null;function ox(e,t){if(e=e.containerInfo,Xo=vu,e=uh(e),pc(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var n=a.getSelection&&a.getSelection();if(n&&n.rangeCount!==0){a=n.anchorNode;var r=n.anchorOffset,c=n.focusNode;n=n.focusOffset;try{a.nodeType,c.nodeType}catch{a=null;break e}var h=0,y=-1,S=-1,M=0,q=0,X=e,z=null;t:for(;;){for(var k;X!==a||r!==0&&X.nodeType!==3||(y=h+r),X!==c||n!==0&&X.nodeType!==3||(S=h+n),X.nodeType===3&&(h+=X.nodeValue.length),(k=X.firstChild)!==null;)z=X,X=k;for(;;){if(X===e)break t;if(z===a&&++M===r&&(y=h),z===c&&++q===n&&(S=h),(k=X.nextSibling)!==null)break;X=z,z=X.parentNode}X=k}a=y===-1||S===-1?null:{start:y,end:S}}else a=null}a=a||{start:0,end:0}}else a=null;for(Ko={focusedElem:e,selectionRange:a},vu=!1,xt=t;xt!==null;)if(t=xt,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,xt=e;else for(;xt!==null;){switch(t=xt,c=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(a=0;a title"))),wt(c,n,a),c[Et]=e,gt(c),n=c;break e;case"link":var h=ap("link","href",r).get(n+(a.href||""));if(h){for(var y=0;yZe&&(h=Ze,Ze=ye,ye=h);var R=sh(y,ye),j=sh(y,Ze);if(R&&j&&(k.rangeCount!==1||k.anchorNode!==R.node||k.anchorOffset!==R.offset||k.focusNode!==j.node||k.focusOffset!==j.offset)){var O=X.createRange();O.setStart(R.node,R.offset),k.removeAllRanges(),ye>Ze?(k.addRange(O),k.extend(j.node,j.offset)):(O.setEnd(j.node,j.offset),k.addRange(O))}}}}for(X=[],k=y;k=k.parentNode;)k.nodeType===1&&X.push({element:k,left:k.scrollLeft,top:k.scrollTop});for(typeof y.focus=="function"&&y.focus(),y=0;ya?32:a,L.T=null,a=Oo,Oo=null;var c=Rn,h=an;if(yt=0,di=Rn=null,an=0,(Qe&6)!==0)throw Error(u(331));var y=Qe;if(Qe|=4,ry(c.current),ly(c,c.current,h,a),Qe=y,ws(0,!1),Ct&&typeof Ct.onPostCommitFiberRoot=="function")try{Ct.onPostCommitFiberRoot(ct,c)}catch{}return!0}finally{F.p=r,L.T=n,jy(e,t)}}function Ty(e,t,a){t=ra(a,t),t=co(e.stateNode,t,2),e=En(e,t,2),e!==null&&(Ki(e,2),Oa(e))}function Ge(e,t,a){if(e.tag===3)Ty(e,e,a);else for(;t!==null;){if(t.tag===3){Ty(t,e,a);break}else if(t.tag===1){var n=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof n.componentDidCatch=="function"&&(An===null||!An.has(n))){e=ra(a,e),a=Am(2),n=En(t,a,2),n!==null&&(Rm(a,n,t,e),Ki(n,2),Oa(n));break}}t=t.return}}function Uo(e,t,a){var n=e.pingCache;if(n===null){n=e.pingCache=new hx;var r=new Set;n.set(t,r)}else r=n.get(t),r===void 0&&(r=new Set,n.set(t,r));r.has(a)||(To=!0,r.add(a),e=gx.bind(null,e,t,a),t.then(e,e))}function gx(e,t,a){var n=e.pingCache;n!==null&&n.delete(t),e.pingedLanes|=e.suspendedLanes&a,e.warmLanes&=~a,Fe===e&&(Me&a)===a&&(lt===4||lt===3&&(Me&62914560)===Me&&300>D()-Pr?(Qe&2)===0&&hi(e,0):Ao|=a,fi===Me&&(fi=0)),Oa(e)}function Ay(e,t){t===0&&(t=Ed()),e=sl(e,t),e!==null&&(Ki(e,t),Oa(e))}function xx(e){var t=e.memoizedState,a=0;t!==null&&(a=t.retryLane),Ay(e,a)}function bx(e,t){var a=0;switch(e.tag){case 31:case 13:var n=e.stateNode,r=e.memoizedState;r!==null&&(a=r.retryLane);break;case 19:n=e.stateNode;break;case 22:n=e.stateNode._retryCache;break;default:throw Error(u(314))}n!==null&&n.delete(t),Ay(e,a)}function Sx(e,t){return rr(e,t)}var su=null,yi=null,ko=!1,ru=!1,Lo=!1,On=0;function Oa(e){e!==yi&&e.next===null&&(yi===null?su=yi=e:yi=yi.next=e),ru=!0,ko||(ko=!0,_x())}function ws(e,t){if(!Lo&&ru){Lo=!0;do for(var a=!1,n=su;n!==null;){if(e!==0){var r=n.pendingLanes;if(r===0)var c=0;else{var h=n.suspendedLanes,y=n.pingedLanes;c=(1<<31-St(42|e)+1)-1,c&=r&~(h&~y),c=c&201326741?c&201326741|1:c?c|2:0}c!==0&&(a=!0,My(n,c))}else c=Me,c=fr(n,n===Fe?c:0,n.cancelPendingCommit!==null||n.timeoutHandle!==-1),(c&3)===0||Xi(n,c)||(a=!0,My(n,c));n=n.next}while(a);Lo=!1}}function Ex(){Ry()}function Ry(){ru=ko=!1;var e=0;On!==0&&Dx()&&(e=On);for(var t=D(),a=null,n=su;n!==null;){var r=n.next,c=Cy(n,t);c===0?(n.next=null,a===null?su=r:a.next=r,r===null&&(yi=a)):(a=n,(e!==0||(c&3)!==0)&&(ru=!0)),n=r}yt!==0&&yt!==5||ws(e),On!==0&&(On=0)}function Cy(e,t){for(var a=e.suspendedLanes,n=e.pingedLanes,r=e.expirationTimes,c=e.pendingLanes&-62914561;0y)break;var q=S.transferSize,X=S.initiatorType;q&&By(X)&&(S=S.responseEnd,h+=q*(S"u"?null:document;function Iy(e,t,a){var n=pi;if(n&&typeof t=="string"&&t){var r=ia(t);r='link[rel="'+e+'"][href="'+r+'"]',typeof a=="string"&&(r+='[crossorigin="'+a+'"]'),Wy.has(r)||(Wy.add(r),e={rel:e,crossOrigin:a,href:t},n.querySelector(r)===null&&(t=n.createElement("link"),wt(t,"link",e),gt(t),n.head.appendChild(t)))}}function Vx(e){nn.D(e),Iy("dns-prefetch",e,null)}function Yx(e,t){nn.C(e,t),Iy("preconnect",e,t)}function Gx(e,t,a){nn.L(e,t,a);var n=pi;if(n&&e&&t){var r='link[rel="preload"][as="'+ia(t)+'"]';t==="image"&&a&&a.imageSrcSet?(r+='[imagesrcset="'+ia(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(r+='[imagesizes="'+ia(a.imageSizes)+'"]')):r+='[href="'+ia(e)+'"]';var c=r;switch(t){case"style":c=vi(e);break;case"script":c=gi(e)}ha.has(c)||(e=g({rel:"preload",href:t==="image"&&a&&a.imageSrcSet?void 0:e,as:t},a),ha.set(c,e),n.querySelector(r)!==null||t==="style"&&n.querySelector(Cs(c))||t==="script"&&n.querySelector(Os(c))||(t=n.createElement("link"),wt(t,"link",e),gt(t),n.head.appendChild(t)))}}function Xx(e,t){nn.m(e,t);var a=pi;if(a&&e){var n=t&&typeof t.as=="string"?t.as:"script",r='link[rel="modulepreload"][as="'+ia(n)+'"][href="'+ia(e)+'"]',c=r;switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":c=gi(e)}if(!ha.has(c)&&(e=g({rel:"modulepreload",href:e},t),ha.set(c,e),a.querySelector(r)===null)){switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(Os(c)))return}n=a.createElement("link"),wt(n,"link",e),gt(n),a.head.appendChild(n)}}}function Kx(e,t,a){nn.S(e,t,a);var n=pi;if(n&&e){var r=ql(n).hoistableStyles,c=vi(e);t=t||"default";var h=r.get(c);if(!h){var y={loading:0,preload:null};if(h=n.querySelector(Cs(c)))y.loading=5;else{e=g({rel:"stylesheet",href:e,"data-precedence":t},a),(a=ha.get(c))&&Po(e,a);var S=h=n.createElement("link");gt(S),wt(S,"link",e),S._p=new Promise(function(M,q){S.onload=M,S.onerror=q}),S.addEventListener("load",function(){y.loading|=1}),S.addEventListener("error",function(){y.loading|=2}),y.loading|=4,du(h,t,n)}h={type:"stylesheet",instance:h,count:1,state:y},r.set(c,h)}}}function Zx(e,t){nn.X(e,t);var a=pi;if(a&&e){var n=ql(a).hoistableScripts,r=gi(e),c=n.get(r);c||(c=a.querySelector(Os(r)),c||(e=g({src:e,async:!0},t),(t=ha.get(r))&&ef(e,t),c=a.createElement("script"),gt(c),wt(c,"link",e),a.head.appendChild(c)),c={type:"script",instance:c,count:1,state:null},n.set(r,c))}}function Fx(e,t){nn.M(e,t);var a=pi;if(a&&e){var n=ql(a).hoistableScripts,r=gi(e),c=n.get(r);c||(c=a.querySelector(Os(r)),c||(e=g({src:e,async:!0,type:"module"},t),(t=ha.get(r))&&ef(e,t),c=a.createElement("script"),gt(c),wt(c,"link",e),a.head.appendChild(c)),c={type:"script",instance:c,count:1,state:null},n.set(r,c))}}function Py(e,t,a,n){var r=(r=ge.current)?fu(r):null;if(!r)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(t=vi(a.href),a=ql(r).hoistableStyles,n=a.get(t),n||(n={type:"style",instance:null,count:0,state:null},a.set(t,n)),n):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){e=vi(a.href);var c=ql(r).hoistableStyles,h=c.get(e);if(h||(r=r.ownerDocument||r,h={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},c.set(e,h),(c=r.querySelector(Cs(e)))&&!c._p&&(h.instance=c,h.state.loading=5),ha.has(e)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},ha.set(e,a),c||Jx(r,e,a,h.state))),t&&n===null)throw Error(u(528,""));return h}if(t&&n!==null)throw Error(u(529,""));return null;case"script":return t=a.async,a=a.src,typeof a=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=gi(a),a=ql(r).hoistableScripts,n=a.get(t),n||(n={type:"script",instance:null,count:0,state:null},a.set(t,n)),n):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function vi(e){return'href="'+ia(e)+'"'}function Cs(e){return'link[rel="stylesheet"]['+e+"]"}function ep(e){return g({},e,{"data-precedence":e.precedence,precedence:null})}function Jx(e,t,a,n){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?n.loading=1:(t=e.createElement("link"),n.preload=t,t.addEventListener("load",function(){return n.loading|=1}),t.addEventListener("error",function(){return n.loading|=2}),wt(t,"link",a),gt(t),e.head.appendChild(t))}function gi(e){return'[src="'+ia(e)+'"]'}function Os(e){return"script[async]"+e}function tp(e,t,a){if(t.count++,t.instance===null)switch(t.type){case"style":var n=e.querySelector('style[data-href~="'+ia(a.href)+'"]');if(n)return t.instance=n,gt(n),n;var r=g({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return n=(e.ownerDocument||e).createElement("style"),gt(n),wt(n,"style",r),du(n,a.precedence,e),t.instance=n;case"stylesheet":r=vi(a.href);var c=e.querySelector(Cs(r));if(c)return t.state.loading|=4,t.instance=c,gt(c),c;n=ep(a),(r=ha.get(r))&&Po(n,r),c=(e.ownerDocument||e).createElement("link"),gt(c);var h=c;return h._p=new Promise(function(y,S){h.onload=y,h.onerror=S}),wt(c,"link",n),t.state.loading|=4,du(c,a.precedence,e),t.instance=c;case"script":return c=gi(a.src),(r=e.querySelector(Os(c)))?(t.instance=r,gt(r),r):(n=a,(r=ha.get(c))&&(n=g({},a),ef(n,r)),e=e.ownerDocument||e,r=e.createElement("script"),gt(r),wt(r,"link",n),e.head.appendChild(r),t.instance=r);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(n=t.instance,t.state.loading|=4,du(n,a.precedence,e));return t.instance}function du(e,t,a){for(var n=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),r=n.length?n[n.length-1]:null,c=r,h=0;h title"):null)}function $x(e,t,a){if(a===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function lp(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function Wx(e,t,a,n){if(a.type==="stylesheet"&&(typeof n.media!="string"||matchMedia(n.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var r=vi(n.href),c=t.querySelector(Cs(r));if(c){t=c._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=mu.bind(e),t.then(e,e)),a.state.loading|=4,a.instance=c,gt(c);return}c=t.ownerDocument||t,n=ep(n),(r=ha.get(r))&&Po(n,r),c=c.createElement("link"),gt(c);var h=c;h._p=new Promise(function(y,S){h.onload=y,h.onerror=S}),wt(c,"link",n),a.instance=c}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(a,t),(t=a.state.preload)&&(a.state.loading&3)===0&&(e.count++,a=mu.bind(e),t.addEventListener("load",a),t.addEventListener("error",a))}}var tf=0;function Ix(e,t){return e.stylesheets&&e.count===0&&pu(e,e.stylesheets),0tf?50:800)+t);return e.unsuspend=a,function(){e.unsuspend=null,clearTimeout(n),clearTimeout(r)}}:null}function mu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)pu(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var yu=null;function pu(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,yu=new Map,t.forEach(Px,e),yu=null,mu.call(e))}function Px(e,t){if(!(t.state.loading&4)){var a=yu.get(e);if(a)var n=a.get(null);else{a=new Map,yu.set(e,a);for(var r=e.querySelectorAll("link[data-precedence],style[data-precedence]"),c=0;c"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(i){console.error(i)}}return l(),mf.exports=c1(),mf.exports}var f1=o1();const d1=zv(f1);/** + * react-router v7.13.2 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var Hp="popstate";function qp(l){return typeof l=="object"&&l!=null&&"pathname"in l&&"search"in l&&"hash"in l&&"state"in l&&"key"in l}function h1(l={}){function i(u,o){var v;let d=(v=o.state)==null?void 0:v.masked,{pathname:m,search:p,hash:x}=d||u.location;return jf("",{pathname:m,search:p,hash:x},o.state&&o.state.usr||null,o.state&&o.state.key||"default",d?{pathname:u.location.pathname,search:u.location.search,hash:u.location.hash}:void 0)}function s(u,o){return typeof o=="string"?o:Ks(o)}return y1(i,s,null,l)}function et(l,i){if(l===!1||l===null||typeof l>"u")throw new Error(i)}function ja(l,i){if(!l){typeof console<"u"&&console.warn(i);try{throw new Error(i)}catch{}}}function m1(){return Math.random().toString(36).substring(2,10)}function Bp(l,i){return{usr:l.state,key:l.key,idx:i,masked:l.unstable_mask?{pathname:l.pathname,search:l.search,hash:l.hash}:void 0}}function jf(l,i,s=null,u,o){return{pathname:typeof l=="string"?l:l.pathname,search:"",hash:"",...typeof i=="string"?Hi(i):i,state:s,key:i&&i.key||u||m1(),unstable_mask:o}}function Ks({pathname:l="/",search:i="",hash:s=""}){return i&&i!=="?"&&(l+=i.charAt(0)==="?"?i:"?"+i),s&&s!=="#"&&(l+=s.charAt(0)==="#"?s:"#"+s),l}function Hi(l){let i={};if(l){let s=l.indexOf("#");s>=0&&(i.hash=l.substring(s),l=l.substring(0,s));let u=l.indexOf("?");u>=0&&(i.search=l.substring(u),l=l.substring(0,u)),l&&(i.pathname=l)}return i}function y1(l,i,s,u={}){let{window:o=document.defaultView,v5Compat:d=!1}=u,m=o.history,p="POP",x=null,v=b();v==null&&(v=0,m.replaceState({...m.state,idx:v},""));function b(){return(m.state||{idx:null}).idx}function g(){p="POP";let H=b(),G=H==null?null:H-v;v=H,x&&x({action:p,location:U.location,delta:G})}function N(H,G){p="PUSH";let Q=qp(H)?H:jf(U.location,H,G);v=b()+1;let K=Bp(Q,v),Z=U.createHref(Q.unstable_mask||Q);try{m.pushState(K,"",Z)}catch(ee){if(ee instanceof DOMException&&ee.name==="DataCloneError")throw ee;o.location.assign(Z)}d&&x&&x({action:p,location:U.location,delta:1})}function T(H,G){p="REPLACE";let Q=qp(H)?H:jf(U.location,H,G);v=b();let K=Bp(Q,v),Z=U.createHref(Q.unstable_mask||Q);m.replaceState(K,"",Z),d&&x&&x({action:p,location:U.location,delta:0})}function B(H){return p1(H)}let U={get action(){return p},get location(){return l(o,m)},listen(H){if(x)throw new Error("A history only accepts one active listener");return o.addEventListener(Hp,g),x=H,()=>{o.removeEventListener(Hp,g),x=null}},createHref(H){return i(o,H)},createURL:B,encodeLocation(H){let G=B(H);return{pathname:G.pathname,search:G.search,hash:G.hash}},push:N,replace:T,go(H){return m.go(H)}};return U}function p1(l,i=!1){let s="http://localhost";typeof window<"u"&&(s=window.location.origin!=="null"?window.location.origin:window.location.href),et(s,"No window.location.(origin|href) available to create URL");let u=typeof l=="string"?l:Ks(l);return u=u.replace(/ $/,"%20"),!i&&u.startsWith("//")&&(u=s+u),new URL(u,s)}function Xv(l,i,s="/"){return v1(l,i,s,!1)}function v1(l,i,s,u){let o=typeof i=="string"?Hi(i):i,d=fn(o.pathname||"/",s);if(d==null)return null;let m=Kv(l);g1(m);let p=null;for(let x=0;p==null&&x{let b={relativePath:v===void 0?m.path||"":v,caseSensitive:m.caseSensitive===!0,childrenIndex:p,route:m};if(b.relativePath.startsWith("/")){if(!b.relativePath.startsWith(u)&&x)return;et(b.relativePath.startsWith(u),`Absolute route path "${b.relativePath}" nested under path "${u}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),b.relativePath=b.relativePath.slice(u.length)}let g=La([u,b.relativePath]),N=s.concat(b);m.children&&m.children.length>0&&(et(m.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${g}".`),Kv(m.children,i,N,g,x)),!(m.path==null&&!m.index)&&i.push({path:g,score:j1(g,m.index),routesMeta:N})};return l.forEach((m,p)=>{var x;if(m.path===""||!((x=m.path)!=null&&x.includes("?")))d(m,p);else for(let v of Zv(m.path))d(m,p,!0,v)}),i}function Zv(l){let i=l.split("/");if(i.length===0)return[];let[s,...u]=i,o=s.endsWith("?"),d=s.replace(/\?$/,"");if(u.length===0)return o?[d,""]:[d];let m=Zv(u.join("/")),p=[];return p.push(...m.map(x=>x===""?d:[d,x].join("/"))),o&&p.push(...m),p.map(x=>l.startsWith("/")&&x===""?"/":x)}function g1(l){l.sort((i,s)=>i.score!==s.score?s.score-i.score:w1(i.routesMeta.map(u=>u.childrenIndex),s.routesMeta.map(u=>u.childrenIndex)))}var x1=/^:[\w-]+$/,b1=3,S1=2,E1=1,_1=10,N1=-2,Qp=l=>l==="*";function j1(l,i){let s=l.split("/"),u=s.length;return s.some(Qp)&&(u+=N1),i&&(u+=S1),s.filter(o=>!Qp(o)).reduce((o,d)=>o+(x1.test(d)?b1:d===""?E1:_1),u)}function w1(l,i){return l.length===i.length&&l.slice(0,-1).every((u,o)=>u===i[o])?l[l.length-1]-i[i.length-1]:0}function T1(l,i,s=!1){let{routesMeta:u}=l,o={},d="/",m=[];for(let p=0;p{if(b==="*"){let B=p[N]||"";m=d.slice(0,d.length-B.length).replace(/(.)\/+$/,"$1")}const T=p[N];return g&&!T?v[b]=void 0:v[b]=(T||"").replace(/%2F/g,"/"),v},{}),pathname:d,pathnameBase:m,pattern:l}}function A1(l,i=!1,s=!0){ja(l==="*"||!l.endsWith("*")||l.endsWith("/*"),`Route path "${l}" will be treated as if it were "${l.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${l.replace(/\*$/,"/*")}".`);let u=[],o="^"+l.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(m,p,x,v,b)=>{if(u.push({paramName:p,isOptional:x!=null}),x){let g=b.charAt(v+m.length);return g&&g!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return l.endsWith("*")?(u.push({paramName:"*"}),o+=l==="*"||l==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?o+="\\/*$":l!==""&&l!=="/"&&(o+="(?:(?=\\/|$))"),[new RegExp(o,i?void 0:"i"),u]}function R1(l){try{return l.split("/").map(i=>decodeURIComponent(i).replace(/\//g,"%2F")).join("/")}catch(i){return ja(!1,`The URL path "${l}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${i}).`),l}}function fn(l,i){if(i==="/")return l;if(!l.toLowerCase().startsWith(i.toLowerCase()))return null;let s=i.endsWith("/")?i.length-1:i.length,u=l.charAt(s);return u&&u!=="/"?null:l.slice(s)||"/"}var C1=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function O1(l,i="/"){let{pathname:s,search:u="",hash:o=""}=typeof l=="string"?Hi(l):l,d;return s?(s=s.replace(/\/\/+/g,"/"),s.startsWith("/")?d=Vp(s.substring(1),"/"):d=Vp(s,i)):d=i,{pathname:d,search:z1(u),hash:U1(o)}}function Vp(l,i){let s=i.replace(/\/+$/,"").split("/");return l.split("/").forEach(o=>{o===".."?s.length>1&&s.pop():o!=="."&&s.push(o)}),s.length>1?s.join("/"):"/"}function gf(l,i,s,u){return`Cannot include a '${l}' character in a manually specified \`to.${i}\` field [${JSON.stringify(u)}]. Please separate it out to the \`to.${s}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function M1(l){return l.filter((i,s)=>s===0||i.route.path&&i.route.path.length>0)}function Pf(l){let i=M1(l);return i.map((s,u)=>u===i.length-1?s.pathname:s.pathnameBase)}function Vu(l,i,s,u=!1){let o;typeof l=="string"?o=Hi(l):(o={...l},et(!o.pathname||!o.pathname.includes("?"),gf("?","pathname","search",o)),et(!o.pathname||!o.pathname.includes("#"),gf("#","pathname","hash",o)),et(!o.search||!o.search.includes("#"),gf("#","search","hash",o)));let d=l===""||o.pathname==="",m=d?"/":o.pathname,p;if(m==null)p=s;else{let g=i.length-1;if(!u&&m.startsWith("..")){let N=m.split("/");for(;N[0]==="..";)N.shift(),g-=1;o.pathname=N.join("/")}p=g>=0?i[g]:"/"}let x=O1(o,p),v=m&&m!=="/"&&m.endsWith("/"),b=(d||m===".")&&s.endsWith("/");return!x.pathname.endsWith("/")&&(v||b)&&(x.pathname+="/"),x}var La=l=>l.join("/").replace(/\/\/+/g,"/"),D1=l=>l.replace(/\/+$/,"").replace(/^\/*/,"/"),z1=l=>!l||l==="?"?"":l.startsWith("?")?l:"?"+l,U1=l=>!l||l==="#"?"":l.startsWith("#")?l:"#"+l,k1=class{constructor(l,i,s,u=!1){this.status=l,this.statusText=i||"",this.internal=u,s instanceof Error?(this.data=s.toString(),this.error=s):this.data=s}};function L1(l){return l!=null&&typeof l.status=="number"&&typeof l.statusText=="string"&&typeof l.internal=="boolean"&&"data"in l}function H1(l){return l.map(i=>i.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var Fv=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function Jv(l,i){let s=l;if(typeof s!="string"||!C1.test(s))return{absoluteURL:void 0,isExternal:!1,to:s};let u=s,o=!1;if(Fv)try{let d=new URL(window.location.href),m=s.startsWith("//")?new URL(d.protocol+s):new URL(s),p=fn(m.pathname,i);m.origin===d.origin&&p!=null?s=p+m.search+m.hash:o=!0}catch{ja(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:u,isExternal:o,to:s}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var $v=["POST","PUT","PATCH","DELETE"];new Set($v);var q1=["GET",...$v];new Set(q1);var qi=A.createContext(null);qi.displayName="DataRouter";var Yu=A.createContext(null);Yu.displayName="DataRouterState";var B1=A.createContext(!1),Wv=A.createContext({isTransitioning:!1});Wv.displayName="ViewTransition";var Q1=A.createContext(new Map);Q1.displayName="Fetchers";var V1=A.createContext(null);V1.displayName="Await";var ta=A.createContext(null);ta.displayName="Navigation";var er=A.createContext(null);er.displayName="Location";var wa=A.createContext({outlet:null,matches:[],isDataRoute:!1});wa.displayName="Route";var ed=A.createContext(null);ed.displayName="RouteError";var Iv="REACT_ROUTER_ERROR",Y1="REDIRECT",G1="ROUTE_ERROR_RESPONSE";function X1(l){if(l.startsWith(`${Iv}:${Y1}:{`))try{let i=JSON.parse(l.slice(28));if(typeof i=="object"&&i&&typeof i.status=="number"&&typeof i.statusText=="string"&&typeof i.location=="string"&&typeof i.reloadDocument=="boolean"&&typeof i.replace=="boolean")return i}catch{}}function K1(l){if(l.startsWith(`${Iv}:${G1}:{`))try{let i=JSON.parse(l.slice(40));if(typeof i=="object"&&i&&typeof i.status=="number"&&typeof i.statusText=="string")return new k1(i.status,i.statusText,i.data)}catch{}}function Z1(l,{relative:i}={}){et(Bi(),"useHref() may be used only in the context of a component.");let{basename:s,navigator:u}=A.useContext(ta),{hash:o,pathname:d,search:m}=tr(l,{relative:i}),p=d;return s!=="/"&&(p=d==="/"?s:La([s,d])),u.createHref({pathname:p,search:m,hash:o})}function Bi(){return A.useContext(er)!=null}function qa(){return et(Bi(),"useLocation() may be used only in the context of a component."),A.useContext(er).location}var Pv="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function eg(l){A.useContext(ta).static||A.useLayoutEffect(l)}function Dl(){let{isDataRoute:l}=A.useContext(wa);return l?uS():F1()}function F1(){et(Bi(),"useNavigate() may be used only in the context of a component.");let l=A.useContext(qi),{basename:i,navigator:s}=A.useContext(ta),{matches:u}=A.useContext(wa),{pathname:o}=qa(),d=JSON.stringify(Pf(u)),m=A.useRef(!1);return eg(()=>{m.current=!0}),A.useCallback((x,v={})=>{if(ja(m.current,Pv),!m.current)return;if(typeof x=="number"){s.go(x);return}let b=Vu(x,JSON.parse(d),o,v.relative==="path");l==null&&i!=="/"&&(b.pathname=b.pathname==="/"?i:La([i,b.pathname])),(v.replace?s.replace:s.push)(b,v.state,v)},[i,s,d,o,l])}var J1=A.createContext(null);function $1(l){let i=A.useContext(wa).outlet;return A.useMemo(()=>i&&A.createElement(J1.Provider,{value:l},i),[i,l])}function tr(l,{relative:i}={}){let{matches:s}=A.useContext(wa),{pathname:u}=qa(),o=JSON.stringify(Pf(s));return A.useMemo(()=>Vu(l,JSON.parse(o),u,i==="path"),[l,o,u,i])}function W1(l,i){return tg(l,i)}function tg(l,i,s){var H;et(Bi(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=A.useContext(ta),{matches:o}=A.useContext(wa),d=o[o.length-1],m=d?d.params:{},p=d?d.pathname:"/",x=d?d.pathnameBase:"/",v=d&&d.route;{let G=v&&v.path||"";ng(p,!v||G.endsWith("*")||G.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${p}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let b=qa(),g;if(i){let G=typeof i=="string"?Hi(i):i;et(x==="/"||((H=G.pathname)==null?void 0:H.startsWith(x)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${x}" but pathname "${G.pathname}" was given in the \`location\` prop.`),g=G}else g=b;let N=g.pathname||"/",T=N;if(x!=="/"){let G=x.replace(/^\//,"").split("/");T="/"+N.replace(/^\//,"").split("/").slice(G.length).join("/")}let B=Xv(l,{pathname:T});ja(v||B!=null,`No routes matched location "${g.pathname}${g.search}${g.hash}" `),ja(B==null||B[B.length-1].route.element!==void 0||B[B.length-1].route.Component!==void 0||B[B.length-1].route.lazy!==void 0,`Matched leaf route at location "${g.pathname}${g.search}${g.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let U=aS(B&&B.map(G=>Object.assign({},G,{params:Object.assign({},m,G.params),pathname:La([x,u.encodeLocation?u.encodeLocation(G.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:G.pathname]),pathnameBase:G.pathnameBase==="/"?x:La([x,u.encodeLocation?u.encodeLocation(G.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:G.pathnameBase])})),o,s);return i&&U?A.createElement(er.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",unstable_mask:void 0,...g},navigationType:"POP"}},U):U}function I1(){let l=rS(),i=L1(l)?`${l.status} ${l.statusText}`:l instanceof Error?l.message:JSON.stringify(l),s=l instanceof Error?l.stack:null,u="rgba(200,200,200, 0.5)",o={padding:"0.5rem",backgroundColor:u},d={padding:"2px 4px",backgroundColor:u},m=null;return console.error("Error handled by React Router default ErrorBoundary:",l),m=A.createElement(A.Fragment,null,A.createElement("p",null,"💿 Hey developer 👋"),A.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",A.createElement("code",{style:d},"ErrorBoundary")," or"," ",A.createElement("code",{style:d},"errorElement")," prop on your route.")),A.createElement(A.Fragment,null,A.createElement("h2",null,"Unexpected Application Error!"),A.createElement("h3",{style:{fontStyle:"italic"}},i),s?A.createElement("pre",{style:o},s):null,m)}var P1=A.createElement(I1,null),ag=class extends A.Component{constructor(l){super(l),this.state={location:l.location,revalidation:l.revalidation,error:l.error}}static getDerivedStateFromError(l){return{error:l}}static getDerivedStateFromProps(l,i){return i.location!==l.location||i.revalidation!=="idle"&&l.revalidation==="idle"?{error:l.error,location:l.location,revalidation:l.revalidation}:{error:l.error!==void 0?l.error:i.error,location:i.location,revalidation:l.revalidation||i.revalidation}}componentDidCatch(l,i){this.props.onError?this.props.onError(l,i):console.error("React Router caught the following error during render",l)}render(){let l=this.state.error;if(this.context&&typeof l=="object"&&l&&"digest"in l&&typeof l.digest=="string"){const s=K1(l.digest);s&&(l=s)}let i=l!==void 0?A.createElement(wa.Provider,{value:this.props.routeContext},A.createElement(ed.Provider,{value:l,children:this.props.component})):this.props.children;return this.context?A.createElement(eS,{error:l},i):i}};ag.contextType=B1;var xf=new WeakMap;function eS({children:l,error:i}){let{basename:s}=A.useContext(ta);if(typeof i=="object"&&i&&"digest"in i&&typeof i.digest=="string"){let u=X1(i.digest);if(u){let o=xf.get(i);if(o)throw o;let d=Jv(u.location,s);if(Fv&&!xf.get(i))if(d.isExternal||u.reloadDocument)window.location.href=d.absoluteURL||d.to;else{const m=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(d.to,{replace:u.replace}));throw xf.set(i,m),m}return A.createElement("meta",{httpEquiv:"refresh",content:`0;url=${d.absoluteURL||d.to}`})}}return l}function tS({routeContext:l,match:i,children:s}){let u=A.useContext(qi);return u&&u.static&&u.staticContext&&(i.route.errorElement||i.route.ErrorBoundary)&&(u.staticContext._deepestRenderedBoundaryId=i.route.id),A.createElement(wa.Provider,{value:l},s)}function aS(l,i=[],s){let u=s==null?void 0:s.state;if(l==null){if(!u)return null;if(u.errors)l=u.matches;else if(i.length===0&&!u.initialized&&u.matches.length>0)l=u.matches;else return null}let o=l,d=u==null?void 0:u.errors;if(d!=null){let b=o.findIndex(g=>g.route.id&&(d==null?void 0:d[g.route.id])!==void 0);et(b>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(d).join(",")}`),o=o.slice(0,Math.min(o.length,b+1))}let m=!1,p=-1;if(s&&u){m=u.renderFallback;for(let b=0;b=0?o=o.slice(0,p+1):o=[o[0]];break}}}}let x=s==null?void 0:s.onError,v=u&&x?(b,g)=>{var N,T;x(b,{location:u.location,params:((T=(N=u.matches)==null?void 0:N[0])==null?void 0:T.params)??{},unstable_pattern:H1(u.matches),errorInfo:g})}:void 0;return o.reduceRight((b,g,N)=>{let T,B=!1,U=null,H=null;u&&(T=d&&g.route.id?d[g.route.id]:void 0,U=g.route.errorElement||P1,m&&(p<0&&N===0?(ng("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),B=!0,H=null):p===N&&(B=!0,H=g.route.hydrateFallbackElement||null)));let G=i.concat(o.slice(0,N+1)),Q=()=>{let K;return T?K=U:B?K=H:g.route.Component?K=A.createElement(g.route.Component,null):g.route.element?K=g.route.element:K=b,A.createElement(tS,{match:g,routeContext:{outlet:b,matches:G,isDataRoute:u!=null},children:K})};return u&&(g.route.ErrorBoundary||g.route.errorElement||N===0)?A.createElement(ag,{location:u.location,revalidation:u.revalidation,component:U,error:T,children:Q(),routeContext:{outlet:null,matches:G,isDataRoute:!0},onError:v}):Q()},null)}function td(l){return`${l} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function nS(l){let i=A.useContext(qi);return et(i,td(l)),i}function lS(l){let i=A.useContext(Yu);return et(i,td(l)),i}function iS(l){let i=A.useContext(wa);return et(i,td(l)),i}function ad(l){let i=iS(l),s=i.matches[i.matches.length-1];return et(s.route.id,`${l} can only be used on routes that contain a unique "id"`),s.route.id}function sS(){return ad("useRouteId")}function rS(){var u;let l=A.useContext(ed),i=lS("useRouteError"),s=ad("useRouteError");return l!==void 0?l:(u=i.errors)==null?void 0:u[s]}function uS(){let{router:l}=nS("useNavigate"),i=ad("useNavigate"),s=A.useRef(!1);return eg(()=>{s.current=!0}),A.useCallback(async(o,d={})=>{ja(s.current,Pv),s.current&&(typeof o=="number"?await l.navigate(o):await l.navigate(o,{fromRouteId:i,...d}))},[l,i])}var Yp={};function ng(l,i,s){!i&&!Yp[l]&&(Yp[l]=!0,ja(!1,s))}A.memo(cS);function cS({routes:l,future:i,state:s,isStatic:u,onError:o}){return tg(l,void 0,{state:s,isStatic:u,onError:o})}function lg({to:l,replace:i,state:s,relative:u}){et(Bi()," may be used only in the context of a component.");let{static:o}=A.useContext(ta);ja(!o," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:d}=A.useContext(wa),{pathname:m}=qa(),p=Dl(),x=Vu(l,Pf(d),m,u==="path"),v=JSON.stringify(x);return A.useEffect(()=>{p(JSON.parse(v),{replace:i,state:s,relative:u})},[p,v,u,i,s]),null}function nd(l){return $1(l.context)}function xa(l){et(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function oS({basename:l="/",children:i=null,location:s,navigationType:u="POP",navigator:o,static:d=!1,unstable_useTransitions:m}){et(!Bi(),"You cannot render a inside another . You should never have more than one in your app.");let p=l.replace(/^\/*/,"/"),x=A.useMemo(()=>({basename:p,navigator:o,static:d,unstable_useTransitions:m,future:{}}),[p,o,d,m]);typeof s=="string"&&(s=Hi(s));let{pathname:v="/",search:b="",hash:g="",state:N=null,key:T="default",unstable_mask:B}=s,U=A.useMemo(()=>{let H=fn(v,p);return H==null?null:{location:{pathname:H,search:b,hash:g,state:N,key:T,unstable_mask:B},navigationType:u}},[p,v,b,g,N,T,u,B]);return ja(U!=null,` is not able to match the URL "${v}${b}${g}" because it does not start with the basename, so the won't render anything.`),U==null?null:A.createElement(ta.Provider,{value:x},A.createElement(er.Provider,{children:i,value:U}))}function fS({children:l,location:i}){return W1(wf(l),i)}function wf(l,i=[]){let s=[];return A.Children.forEach(l,(u,o)=>{if(!A.isValidElement(u))return;let d=[...i,o];if(u.type===A.Fragment){s.push.apply(s,wf(u.props.children,d));return}et(u.type===xa,`[${typeof u.type=="string"?u.type:u.type.name}] is not a component. All component children of must be a or `),et(!u.props.index||!u.props.children,"An index route cannot have child routes.");let m={id:u.props.id||d.join("-"),caseSensitive:u.props.caseSensitive,element:u.props.element,Component:u.props.Component,index:u.props.index,path:u.props.path,middleware:u.props.middleware,loader:u.props.loader,action:u.props.action,hydrateFallbackElement:u.props.hydrateFallbackElement,HydrateFallback:u.props.HydrateFallback,errorElement:u.props.errorElement,ErrorBoundary:u.props.ErrorBoundary,hasErrorBoundary:u.props.hasErrorBoundary===!0||u.props.ErrorBoundary!=null||u.props.errorElement!=null,shouldRevalidate:u.props.shouldRevalidate,handle:u.props.handle,lazy:u.props.lazy};u.props.children&&(m.children=wf(u.props.children,d)),s.push(m)}),s}var Au="get",Ru="application/x-www-form-urlencoded";function Gu(l){return typeof HTMLElement<"u"&&l instanceof HTMLElement}function dS(l){return Gu(l)&&l.tagName.toLowerCase()==="button"}function hS(l){return Gu(l)&&l.tagName.toLowerCase()==="form"}function mS(l){return Gu(l)&&l.tagName.toLowerCase()==="input"}function yS(l){return!!(l.metaKey||l.altKey||l.ctrlKey||l.shiftKey)}function pS(l,i){return l.button===0&&(!i||i==="_self")&&!yS(l)}var wu=null;function vS(){if(wu===null)try{new FormData(document.createElement("form"),0),wu=!1}catch{wu=!0}return wu}var gS=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function bf(l){return l!=null&&!gS.has(l)?(ja(!1,`"${l}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Ru}"`),null):l}function xS(l,i){let s,u,o,d,m;if(hS(l)){let p=l.getAttribute("action");u=p?fn(p,i):null,s=l.getAttribute("method")||Au,o=bf(l.getAttribute("enctype"))||Ru,d=new FormData(l)}else if(dS(l)||mS(l)&&(l.type==="submit"||l.type==="image")){let p=l.form;if(p==null)throw new Error('Cannot submit a + + )} + + {/* @ts-ignore — whereby-embed is a web component */} + + + ); +} diff --git a/www/appv2/src/components/layout/Footer.tsx b/www/appv2/src/components/layout/Footer.tsx new file mode 100644 index 00000000..bc8425cc --- /dev/null +++ b/www/appv2/src/components/layout/Footer.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Link } from "react-router-dom"; + +export function Footer() { + return ( +
+ + © 2024 Reflector Archive + +
+ Learn more + Privacy policy +
+
+ ); +} diff --git a/www/appv2/src/components/layout/TopNav.tsx b/www/appv2/src/components/layout/TopNav.tsx new file mode 100644 index 00000000..f105d604 --- /dev/null +++ b/www/appv2/src/components/layout/TopNav.tsx @@ -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 = ({ 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(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 ( +
+
+
+ + Reflector Logo + Reflector +
+ +
+ +
+ +
+
setIsDropdownOpen(!isDropdownOpen)} + > + {user?.name ? ( + {user.name.charAt(0)} + ) : ( + C + )} +
+ + {isDropdownOpen && ( +
+
+

{user?.name || 'Curator'}

+

{user?.email || 'admin@reflector.com'}

+
+ +
+ )} +
+
+ + {isMobileMenuOpen && ( +
+ {links.map((link) => { + const isActive = location.pathname === link.href || (link.href !== '/' && location.pathname.startsWith(`${link.href}/`)); + return ( + 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} + + ); + })} +
+ )} +
+ ); +}; diff --git a/www/appv2/src/components/rooms/AddRoomModal.tsx b/www/appv2/src/components/rooms/AddRoomModal.tsx new file mode 100644 index 00000000..16df0527 --- /dev/null +++ b/www/appv2/src/components/rooms/AddRoomModal.tsx @@ -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({ + 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 ( +
+
+ + {/* Header */} +
+
+ +

+ {editRoomId ? 'Edit Room' : 'New Room'} +

+ {isFetchingRoom && } +
+ +
+ + {/* Tab Bar */} +
+
+ {tabs.map(tab => ( + + ))} +
+ + {/* Body */} +
+ + + {activeTab === 'general' && ( +
+
+ + +

No spaces allowed. E.g. my-room

+
+ +
+ + +
+ +
+ +
+ + {platform !== 'daily' && ( +
+ + +
+ )} + +
+ + +
+ + {recordingType === 'cloud' && platform !== 'daily' && ( +
+ + +
+ )} + +
+ + +
+
+ )} + + {activeTab === 'calendar' && ( +
+ +

When enabled, a calendar feed URL will be generated.

+
+ )} + + {activeTab === 'sharing' && ( +
+ {emailEnabled && ( +
+ + {emailTranscript && ( +
+ + +
+ )} +
+ )} + + {zulipEnabled && ( +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ )} +
+ )} + + {activeTab === 'webhooks' && ( +
+
+ +
+
+ +
+ +
+ {errors.webhookUrl &&

{errors.webhookUrl.message}

} +
+ + {webhookUrl && editRoomId && ( +
+ + {testResult && ( +
+ {testResult.msg} +
+ )} +
+ )} +
+ )} + +
+ + {/* Footer */} +
+ + +
+ +
+
+ ); +} diff --git a/www/appv2/src/components/rooms/DailyRoom.tsx b/www/appv2/src/components/rooms/DailyRoom.tsx new file mode 100644 index 00000000..a81d67e1 --- /dev/null +++ b/www/appv2/src/components/rooms/DailyRoom.tsx @@ -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({}); + 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 => { + 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(null); + + const joinMutation = useRoomJoinMeeting(); + const startRecordingMutation = useMeetingStartRecording(); + const [joinedMeeting, setJoinedMeeting] = useState(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 ( +
+ +
+ ); + } + + if (joinMutation.isError) { + return ( +
+

Failed to join meeting. Please try again.

+
+ ); + } + + if (!roomUrl) return null; + + return ( +
+
+
+ ); +} diff --git a/www/appv2/src/components/rooms/MeetingMinimalHeader.tsx b/www/appv2/src/components/rooms/MeetingMinimalHeader.tsx new file mode 100644 index 00000000..0a1f6cf7 --- /dev/null +++ b/www/appv2/src/components/rooms/MeetingMinimalHeader.tsx @@ -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 ( +
+
+ + + + + {roomTitle} + +
+ +
+ {showCreateButton && onCreateMeeting && ( + + )} + {showLeaveButton && ( + + )} +
+
+ ); +} diff --git a/www/appv2/src/components/rooms/MeetingSelection.tsx b/www/appv2/src/components/rooms/MeetingSelection.tsx new file mode 100644 index 00000000..336eeb6f --- /dev/null +++ b/www/appv2/src/components/rooms/MeetingSelection.tsx @@ -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(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 ( +
+ +

Retrieving meetings...

+
+ ); + } + + if (error) { + return ( +
+

Error

+

Failed to load meetings

+
+ ); + } + + return ( +
+ {isCreatingMeeting && ( +
+
+ +

Creating meeting...

+
+
+ )} + + + +
+ {/* Current Ongoing Meetings */} + {currentMeetings.length > 0 ? ( +
+ {currentMeetings.map((meeting) => ( + +
+
+
+ +

+ {(meeting.calendar_metadata as any)?.title || 'Live Meeting'} +

+
+ + {isOwner && (meeting.calendar_metadata as any)?.description && ( +

+ {(meeting.calendar_metadata as any).description} +

+ )} + +
+
+ + + {meeting.num_clients || 0} participant{meeting.num_clients !== 1 ? 's' : ''} + +
+
+ + Started {formatStartedAgo(new Date(meeting.start_date))} +
+
+ + {isOwner && (meeting.calendar_metadata as any)?.attendees && ( +
+ {(meeting.calendar_metadata as any).attendees.slice(0, 4).map((att: any, idx: number) => ( + + {att.name || att.email} + + ))} + {(meeting.calendar_metadata as any).attendees.length > 4 && ( + + + {(meeting.calendar_metadata as any).attendees.length - 4} more + + )} +
+ )} +
+ +
+ + {isOwner && ( + + )} +
+
+
+ ))} +
+ ) : upcomingMeetings.length > 0 ? ( + /* Upcoming Meetings - Big Display */ +
+

+ Upcoming Meeting{upcomingMeetings.length > 1 ? 's' : ''} +

+ {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 ( + +
+
+
+ +

+ {(meeting.calendar_metadata as any)?.title || 'Upcoming Meeting'} +

+
+ + {isOwner && (meeting.calendar_metadata as any)?.description && ( +

+ {(meeting.calendar_metadata as any).description} +

+ )} + +
+ + Starts in {minutesUntilStart} minute{minutesUntilStart !== 1 ? 's' : ''} + + + {formatDateTime(new Date(meeting.start_date))} + +
+ + {isOwner && (meeting.calendar_metadata as any)?.attendees && ( +
+ {(meeting.calendar_metadata as any).attendees.slice(0, 4).map((att: any, idx: number) => ( + + {att.name || att.email} + + ))} + {(meeting.calendar_metadata as any).attendees.length > 4 && ( + + + {(meeting.calendar_metadata as any).attendees.length - 4} more + + )} +
+ )} +
+ +
+ + {isOwner && ( + + )} +
+
+
+ ); + })} +
+ ) : null} + + {/* Small Upcoming Display if Ongoing EXISTS */} + {currentMeetings.length > 0 && upcomingMeetings.length > 0 && ( +
+

Starting Soon

+
+ {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 ( +
+
+
+ + + {(meeting.calendar_metadata as any)?.title || 'Upcoming'} + +
+ + in {minutesUntilStart} minute{minutesUntilStart !== 1 ? 's' : ''} + + + Starts: {formatDateTime(new Date(meeting.start_date))} + + +
+
+ ); + })} +
+
+ )} + + {/* No Meetings Fallback */} + {currentMeetings.length === 0 && upcomingMeetings.length === 0 && ( +
+
+ +
+

No meetings active

+

+ There are no ongoing or upcoming calendar meetings parsed for this room currently. +

+
+ )} +
+ + 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} + /> +
+ ); +} diff --git a/www/appv2/src/components/rooms/WherebyRoom.tsx b/www/appv2/src/components/rooms/WherebyRoom.tsx new file mode 100644 index 00000000..94e51605 --- /dev/null +++ b/www/appv2/src/components/rooms/WherebyRoom.tsx @@ -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; +}) { + const previousFocusRef = useRef(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 ( + + ); +} + +export default function WherebyRoom({ meeting, room }: WherebyRoomProps) { + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(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 ( + <> + + {showConsentButton && ( + 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 & { + room: string; + style?: React.CSSProperties; + ref?: React.Ref; + }, + HTMLElement + >; + } + } +} diff --git a/www/appv2/src/components/transcripts/ProcessingView.tsx b/www/appv2/src/components/transcripts/ProcessingView.tsx new file mode 100644 index 00000000..de0dbe2c --- /dev/null +++ b/www/appv2/src/components/transcripts/ProcessingView.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Bot, Sparkles } from 'lucide-react'; + +export function ProcessingView() { + return ( +
+
+
+
+ +
+
+ +
+

Curating your archive...

+

+ The Reflector extraction engine is analyzing the audio. This typically takes a few moments depending on the recording length. +

+
+ +
+
+
+
+
+
+ ); +} diff --git a/www/appv2/src/components/transcripts/RecordView.tsx b/www/appv2/src/components/transcripts/RecordView.tsx new file mode 100644 index 00000000..c97b0856 --- /dev/null +++ b/www/appv2/src/components/transcripts/RecordView.tsx @@ -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(null); + + const [wavesurfer, setWavesurfer] = useState(null); + const [recordPlugin, setRecordPlugin] = useState(null); + + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [currentStream, setCurrentStream] = useState(null); + const [isConfirmEndOpen, setIsConfirmEndOpen] = useState(false); + + const { permissionOk, requestPermission, audioDevices } = useAudioDevice(); + const [selectedDevice, setSelectedDevice] = useState(''); + + // 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 ( +
+
+
+

Live Recording

+

+ Capturing audio for transcript ID: {transcriptId.substring(0, 8)}... +

+
+ +
+ +
+
+ +
+ {/* Dynamic websocket ping duration display mapped off python API output */} + {isRecording && ( +
+ + {formatDuration(wsData.duration)} +
+ )} + + {/* Visualization Area */} +
+ {!permissionOk && !isRecording && ( +
+ + +
+ )} +
+
+ + {/* Play/Pause/Stop Global Controls */} +
+ {!isRecording ? ( + + ) : ( + <> + {/* 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. + */} + + + + + )} +
+
+ + {/* Live Transcript Pane tracking wsData real-time ingestion */} +
+

+ {isRecording && } + Live Transcript Pipeline +

+ +
+ {wsData.transcriptTextLive || wsData.accumulatedText ? ( +
+ {wsData.accumulatedText.replace(wsData.transcriptTextLive, '').trim()} + {wsData.transcriptTextLive} +
+ ) : ( +
+ {isRecording ? "Transmitting audio and calculating text..." : "Connect WebRTC to preview transcript pipeline."} +
+ )} +
+
+ + 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} + /> +
+ ); +} diff --git a/www/appv2/src/components/transcripts/UploadView.tsx b/www/appv2/src/components/transcripts/UploadView.tsx new file mode 100644 index 00000000..6d94e2d6 --- /dev/null +++ b/www/appv2/src/components/transcripts/UploadView.tsx @@ -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(null); + const uploadMutation = useTranscriptUploadAudio(); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const triggerFileUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + 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 ( +
+
+

Upload Meeting Audio

+

+ Select an audio or video file to generate an editorial transcript. +

+
+ +
+
+ {progress === 100 ? : } +
+ + {progress > 0 && progress < 100 ? ( +
+
+ Uploading... + {progress}% +
+
+
+
+
+ ) : progress === 100 ? ( +
Upload complete! Processing will begin momentarily.
+ ) : ( + <> +
+

Click to select a file

+

Supported formats: .mp3, .m4a, .wav, .mp4, .mov, .webm

+
+ + {error &&

{error}

} + + )} + + +
+
+ ); +} diff --git a/www/appv2/src/components/transcripts/correction/CorrectionEditor.tsx b/www/appv2/src/components/transcripts/correction/CorrectionEditor.tsx new file mode 100644 index 00000000..69a18dc3 --- /dev/null +++ b/www/appv2/src/components/transcripts/correction/CorrectionEditor.tsx @@ -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(null); + const stateSelectedText = useState(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 ( +
+
+
+
+ + +
+ +
+ + {currentIndex >= 0 ? currentIndex + 1 : 0} / {topics.length} + +

+ {currentTopic?.title || "Loading..."} +

+
+
+ + +
+ +
+ {/* Editor Central Area */} +
+
+

+ + Correction Mode +

+ + {currentTopicId ? ( + + ) : ( +
No topic selected
+ )} +
+
+ + {/* Participant Assignment Sidebar */} +
+ {currentTopicId && ( + + )} +
+
+
+ ); +} diff --git a/www/appv2/src/components/transcripts/correction/ParticipantSidebar.tsx b/www/appv2/src/components/transcripts/correction/ParticipantSidebar.tsx new file mode 100644 index 00000000..6b6664a0 --- /dev/null +++ b/www/appv2/src/components/transcripts/correction/ParticipantSidebar.tsx @@ -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(null); + const [selectedText, setSelectedText] = stateSelectedText; + const [selectedParticipant, setSelectedParticipant] = useState(); + const [action, setAction] = useState<"Create" | "Create to rename" | "Create and assign" | "Rename" | null>(null); + const [oneMatch, setOneMatch] = useState(); + + 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) => { + 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) => { + 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 ( +
+
e.stopPropagation()}> + + +
+ +
e.stopPropagation()}> + {participants?.map((participant) => ( +
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") + }`} + > + {participant.name} +
+ {action === "Create to rename" && !selectedParticipant && !loading && ( + + )} + {selectedTextIsTimeSlice(selectedText) && !loading && ( + + )} + +
+
+ ))} +
+
+ ); +} diff --git a/www/appv2/src/components/transcripts/correction/TopicWordsEditor.tsx b/www/appv2/src/components/transcripts/correction/TopicWordsEditor.tsx new file mode 100644 index 00000000..6975aaae --- /dev/null +++ b/www/appv2/src/components/transcripts/correction/TopicWordsEditor.tsx @@ -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>, + ]; + 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 ( +
+ +
+ ); + } + + if (topicWithWords && participants) { + return ( +
+ {topicWithWords.words_per_speaker?.map((speakerWithWords: any, index: number) => ( +

+ + {getSpeakerName(speakerWithWords.speaker)}: + + {speakerWithWords.words.map((word: any, wIndex: number) => { + const isActive = + selectedTextIsTimeSlice(selectedText) && + selectedText.start <= word.start && + selectedText.end >= word.end; + return ( + + {word.text}{" "} + + ); + })} +

+ ))} +
+ ); + } + + return null; +} diff --git a/www/appv2/src/components/transcripts/correction/types.ts b/www/appv2/src/components/transcripts/correction/types.ts new file mode 100644 index 00000000..f94ee58e --- /dev/null +++ b/www/appv2/src/components/transcripts/correction/types.ts @@ -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" + ); +} diff --git a/www/appv2/src/components/ui/Button.tsx b/www/appv2/src/components/ui/Button.tsx new file mode 100644 index 00000000..c91163bf --- /dev/null +++ b/www/appv2/src/components/ui/Button.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'tertiary'; +} + +export const Button = React.forwardRef( + ({ 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.displayName = 'Button'; diff --git a/www/appv2/src/components/ui/Card.tsx b/www/appv2/src/components/ui/Card.tsx new file mode 100644 index 00000000..59c7d5d9 --- /dev/null +++ b/www/appv2/src/components/ui/Card.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +interface CardProps extends React.HTMLAttributes {} + +export const Card = React.forwardRef( + ({ className = '', children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +Card.displayName = 'Card'; diff --git a/www/appv2/src/components/ui/Checkbox.tsx b/www/appv2/src/components/ui/Checkbox.tsx new file mode 100644 index 00000000..17506b0c --- /dev/null +++ b/www/appv2/src/components/ui/Checkbox.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface CheckboxProps extends React.InputHTMLAttributes { + label?: string; +} + +export const Checkbox = React.forwardRef( + ({ className = '', label, ...props }, ref) => { + return ( + + ); + } +); + +Checkbox.displayName = 'Checkbox'; diff --git a/www/appv2/src/components/ui/ConfirmModal.tsx b/www/appv2/src/components/ui/ConfirmModal.tsx new file mode 100644 index 00000000..162a7e55 --- /dev/null +++ b/www/appv2/src/components/ui/ConfirmModal.tsx @@ -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 ( +
+ {/* Backdrop */} +
!isLoading && onClose()} + /> + + {/* Modal Box */} +
+ + +
+
+
+ {isDestructive ? : } +
+ +
+

{title}

+

+ {description} +

+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/www/appv2/src/components/ui/FieldError.tsx b/www/appv2/src/components/ui/FieldError.tsx new file mode 100644 index 00000000..aeee30e5 --- /dev/null +++ b/www/appv2/src/components/ui/FieldError.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +interface FieldErrorProps { + message?: string; +} + +export const FieldError: React.FC = ({ message }) => { + if (!message) return null; + + return ( + + {message} + + ); +}; diff --git a/www/appv2/src/components/ui/Input.tsx b/www/appv2/src/components/ui/Input.tsx new file mode 100644 index 00000000..267e3b36 --- /dev/null +++ b/www/appv2/src/components/ui/Input.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes {} + +export const Input = React.forwardRef( + ({ className = '', ...props }, ref) => { + return ( + + ); + } +); + +Input.displayName = 'Input'; diff --git a/www/appv2/src/components/ui/Select.tsx b/www/appv2/src/components/ui/Select.tsx new file mode 100644 index 00000000..6d0512b4 --- /dev/null +++ b/www/appv2/src/components/ui/Select.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +interface SelectProps extends React.SelectHTMLAttributes {} + +export const Select = React.forwardRef( + ({ className = '', children, ...props }, ref) => { + return ( + + ); + } +); + +Select.displayName = 'Select'; diff --git a/www/appv2/src/hooks/rooms/useRoomDefaultMeeting.ts b/www/appv2/src/hooks/rooms/useRoomDefaultMeeting.ts new file mode 100644 index 00000000..525d4727 --- /dev/null +++ b/www/appv2/src/hooks/rooms/useRoomDefaultMeeting.ts @@ -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(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; diff --git a/www/appv2/src/hooks/transcripts/useWebRTC.ts b/www/appv2/src/hooks/transcripts/useWebRTC.ts new file mode 100644 index 00000000..b8bcafda --- /dev/null +++ b/www/appv2/src/hooks/transcripts/useWebRTC.ts @@ -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(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((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; +}; diff --git a/www/appv2/src/hooks/transcripts/useWebSockets.ts b/www/appv2/src/hooks/transcripts/useWebSockets.ts new file mode 100644 index 00000000..bd5a4452 --- /dev/null +++ b/www/appv2/src/hooks/transcripts/useWebSockets.ts @@ -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(""); + const [accumulatedText, setAccumulatedText] = useState(""); + const [title, setTitle] = useState(""); + const [topics, setTopics] = useState([]); + const [waveform, setWaveForm] = useState(null); + const [duration, setDuration] = useState(null); + const [finalSummary, setFinalSummary] = useState({ summary: "" }); + const [status, setStatus] = useState(null); + + const [textQueue, setTextQueue] = useState([]); + 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 | 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, + }; +}; diff --git a/www/appv2/src/hooks/transcripts/webSocketReconnect.ts b/www/appv2/src/hooks/transcripts/webSocketReconnect.ts new file mode 100644 index 00000000..767dae33 --- /dev/null +++ b/www/appv2/src/hooks/transcripts/webSocketReconnect.ts @@ -0,0 +1,5 @@ +export const MAX_RETRIES = 10; + +export function getReconnectDelayMs(retryIndex: number): number { + return Math.min(1000 * Math.pow(2, retryIndex), 30000); +} diff --git a/www/appv2/src/hooks/transcripts/webSocketTypes.ts b/www/appv2/src/hooks/transcripts/webSocketTypes.ts new file mode 100644 index 00000000..0bfb96a9 --- /dev/null +++ b/www/appv2/src/hooks/transcripts/webSocketTypes.ts @@ -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; +}; diff --git a/www/appv2/src/hooks/useAudioDevice.ts b/www/appv2/src/hooks/useAudioDevice.ts new file mode 100644 index 00000000..96e6cb5d --- /dev/null +++ b/www/appv2/src/hooks/useAudioDevice.ts @@ -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(false); + const [permissionDenied, setPermissionDenied] = useState(false); + const [audioDevices, setAudioDevices] = useState([]); + 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 => { + 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 => { + 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, + }; +}; diff --git a/www/appv2/src/lib/AuthProvider.tsx b/www/appv2/src/lib/AuthProvider.tsx new file mode 100644 index 00000000..af92c08d --- /dev/null +++ b/www/appv2/src/lib/AuthProvider.tsx @@ -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; + update: () => Promise; +}; + +const AuthContext = createContext(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(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 ( + {children} + ); +} + +// ─── Hook ──────────────────────────────────────────────────────────────────── + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/www/appv2/src/lib/UserEventsProvider.tsx b/www/appv2/src/lib/UserEventsProvider.tsx new file mode 100644 index 00000000..40306c30 --- /dev/null +++ b/www/appv2/src/lib/UserEventsProvider.tsx @@ -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(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}; +} diff --git a/www/appv2/src/lib/apiClient.ts b/www/appv2/src/lib/apiClient.ts new file mode 100644 index 00000000..5f6c6468 --- /dev/null +++ b/www/appv2/src/lib/apiClient.ts @@ -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({ + 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(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; +}; diff --git a/www/appv2/src/lib/apiHooks.ts b/www/appv2/src/lib/apiHooks.ts new file mode 100644 index 00000000..ec38f9ce --- /dev/null +++ b/www/appv2/src/lib/apiHooks.ts @@ -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([ + "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, + }, + ); +} diff --git a/www/appv2/src/lib/array.ts b/www/appv2/src/lib/array.ts new file mode 100644 index 00000000..f47aaa42 --- /dev/null +++ b/www/appv2/src/lib/array.ts @@ -0,0 +1,12 @@ +export type NonEmptyArray = [T, ...T[]]; +export const isNonEmptyArray = (arr: T[]): arr is NonEmptyArray => + arr.length > 0; +export const assertNonEmptyArray = ( + arr: T[], + err?: string, +): NonEmptyArray => { + if (isNonEmptyArray(arr)) { + return arr; + } + throw new Error(err ?? "Expected non-empty array"); +}; diff --git a/www/appv2/src/lib/consent/ConsentDialog.tsx b/www/appv2/src/lib/consent/ConsentDialog.tsx new file mode 100644 index 00000000..f03ef202 --- /dev/null +++ b/www/appv2/src/lib/consent/ConsentDialog.tsx @@ -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(null); + + useEffect(() => { + // Auto-focus accept button so Escape key works + acceptButtonRef.current?.focus(); + }, []); + + return ( +
+
+

+ {CONSENT_DIALOG_TEXT.question} +

+
+ + +
+
+
+ ); +} diff --git a/www/appv2/src/lib/consent/ConsentDialogButton.tsx b/www/appv2/src/lib/consent/ConsentDialogButton.tsx new file mode 100644 index 00000000..48961ca4 --- /dev/null +++ b/www/appv2/src/lib/consent/ConsentDialogButton.tsx @@ -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 ( + + ); +} diff --git a/www/appv2/src/lib/consent/RecordingIndicator.tsx b/www/appv2/src/lib/consent/RecordingIndicator.tsx new file mode 100644 index 00000000..eda49848 --- /dev/null +++ b/www/appv2/src/lib/consent/RecordingIndicator.tsx @@ -0,0 +1,15 @@ +/** + * RecordingIndicator — visual indicator that a meeting is being recorded. + */ + +export function RecordingIndicator() { + return ( +
+ + + + + Recording +
+ ); +} diff --git a/www/appv2/src/lib/consent/constants.ts b/www/appv2/src/lib/consent/constants.ts new file mode 100644 index 00000000..41e7c7e1 --- /dev/null +++ b/www/appv2/src/lib/consent/constants.ts @@ -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; diff --git a/www/appv2/src/lib/consent/index.ts b/www/appv2/src/lib/consent/index.ts new file mode 100644 index 00000000..989bbd11 --- /dev/null +++ b/www/appv2/src/lib/consent/index.ts @@ -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"; diff --git a/www/appv2/src/lib/consent/types.ts b/www/appv2/src/lib/consent/types.ts new file mode 100644 index 00000000..62446805 --- /dev/null +++ b/www/appv2/src/lib/consent/types.ts @@ -0,0 +1,14 @@ +import { MeetingId } from "../types"; + +export type ConsentDialogResult = { + showConsentModal: () => void; + consentState: { + ready: boolean; + consentForMeetings?: Map; + }; + hasAnswered: (meetingId: MeetingId) => boolean; + hasAccepted: (meetingId: MeetingId) => boolean; + consentLoading: boolean; + showRecordingIndicator: boolean; + showConsentButton: boolean; +}; diff --git a/www/appv2/src/lib/consent/useConsentDialog.tsx b/www/appv2/src/lib/consent/useConsentDialog.tsx new file mode 100644 index 00000000..1290d6a9 --- /dev/null +++ b/www/appv2/src/lib/consent/useConsentDialog.tsx @@ -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, + }; +} diff --git a/www/appv2/src/lib/consent/utils.ts b/www/appv2/src/lib/consent/utils.ts new file mode 100644 index 00000000..4ac55938 --- /dev/null +++ b/www/appv2/src/lib/consent/utils.ts @@ -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"; +}; diff --git a/www/appv2/src/lib/errorContext.tsx b/www/appv2/src/lib/errorContext.tsx new file mode 100644 index 00000000..f56e5565 --- /dev/null +++ b/www/appv2/src/lib/errorContext.tsx @@ -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(undefined); + +export function ErrorProvider({ children }: { children: React.ReactNode }) { + const [errorState, setErrorState] = useState({ + 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; +} diff --git a/www/appv2/src/lib/errorUtils.ts b/www/appv2/src/lib/errorUtils.ts new file mode 100644 index 00000000..1512230c --- /dev/null +++ b/www/appv2/src/lib/errorUtils.ts @@ -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 => { + 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 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 any)[]>( + ...fs: Fns +): ReturnTypes { + 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); + return results as ReturnTypes; +} diff --git a/www/appv2/src/lib/features.ts b/www/appv2/src/lib/features.ts new file mode 100644 index 00000000..9966af5a --- /dev/null +++ b/www/appv2/src/lib/features.ts @@ -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>; + +export const DEFAULT_FEATURES: Features = { + requireLogin: true, + privacy: true, + browse: true, + sendToZulip: true, + rooms: true, + emailTranscript: false, +} as const; + +const FEATURE_TO_ENV: Record = { + 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"; +}; diff --git a/www/appv2/src/lib/queryClient.ts b/www/appv2/src/lib/queryClient.ts new file mode 100644 index 00000000..7dacaf34 --- /dev/null +++ b/www/appv2/src/lib/queryClient.ts @@ -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, + }, + }, +}); diff --git a/www/appv2/src/lib/recordingConsentContext.tsx b/www/appv2/src/lib/recordingConsentContext.tsx new file mode 100644 index 00000000..1811fe2e --- /dev/null +++ b/www/appv2/src/lib/recordingConsentContext.tsx @@ -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; + +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({ 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(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(); + 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 ( + + {children} + + ); +}; diff --git a/www/appv2/src/lib/reflector-api.d.ts b/www/appv2/src/lib/reflector-api.d.ts new file mode 100644 index 00000000..f01ecabc --- /dev/null +++ b/www/appv2/src/lib/reflector-api.d.ts @@ -0,0 +1,4421 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metrics + * @description Endpoint that serves Prometheus metrics. + */ + get: operations["metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/consent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Meeting Audio Consent */ + post: operations["v1_meeting_audio_consent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/deactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Meeting Deactivate */ + patch: operations["v1_meeting_deactivate"]; + trace?: never; + }; + "/v1/meetings/{meeting_id}/recordings/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start Recording + * @description Start cloud or raw-tracks recording via Daily.co REST API. + * + * Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time. + * Uses different instanceIds for cloud vs raw-tracks (same won't work) + */ + post: operations["v1_start_recording"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/email-recipient": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add Email Recipient + * @description Add an email address to receive the transcript link when processing completes. + */ + post: operations["v1_add_email_recipient"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List */ + get: operations["v1_rooms_list"]; + put?: never; + /** Rooms Create */ + post: operations["v1_rooms_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get */ + get: operations["v1_rooms_get"]; + put?: never; + post?: never; + /** Rooms Delete */ + delete: operations["v1_rooms_delete"]; + options?: never; + head?: never; + /** Rooms Update */ + patch: operations["v1_rooms_update"]; + trace?: never; + }; + "/v1/rooms/name/{room_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get By Name */ + get: operations["v1_rooms_get_by_name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meeting": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Create Meeting */ + post: operations["v1_rooms_create_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}/webhook/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Test Webhook + * @description Test webhook configuration by sending a sample payload. + */ + post: operations["v1_rooms_test_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/ics/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Sync Ics */ + post: operations["v1_rooms_sync_ics"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/ics/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Ics Status */ + get: operations["v1_rooms_ics_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Meetings */ + get: operations["v1_rooms_list_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/upcoming": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Upcoming Meetings */ + get: operations["v1_rooms_list_upcoming_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Active Meetings */ + get: operations["v1_rooms_list_active_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Rooms Get Meeting + * @description Get a single meeting by ID within a specific room. + */ + get: operations["v1_rooms_get_meeting"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/join": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Join Meeting */ + post: operations["v1_rooms_join_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcripts List */ + get: operations["v1_transcripts_list"]; + put?: never; + /** Transcripts Create */ + post: operations["v1_transcripts_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Transcripts Search + * @description Full-text search across transcript titles and content. + */ + get: operations["v1_transcripts_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get */ + get: operations["v1_transcript_get"]; + put?: never; + post?: never; + /** Transcript Delete */ + delete: operations["v1_transcript_delete"]; + options?: never; + head?: never; + /** Transcript Update */ + patch: operations["v1_transcript_update"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics */ + get: operations["v1_transcript_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/with-words": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words */ + get: operations["v1_transcript_get_topics_with_words"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words Per Speaker */ + get: operations["v1_transcript_get_topics_with_words_per_speaker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/zulip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Post To Zulip */ + post: operations["v1_transcript_post_to_zulip"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Send Email */ + post: operations["v1_transcript_send_email"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/mp3": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Mp3 */ + get: operations["v1_transcript_get_audio_mp3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + /** Transcript Get Audio Mp3 */ + head: operations["v1_transcript_head_audio_mp3"]; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/waveform": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Waveform */ + get: operations["v1_transcript_get_audio_waveform"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participants */ + get: operations["v1_transcript_get_participants"]; + put?: never; + /** Transcript Add Participant */ + post: operations["v1_transcript_add_participant"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants/{participant_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participant */ + get: operations["v1_transcript_get_participant"]; + put?: never; + post?: never; + /** Transcript Delete Participant */ + delete: operations["v1_transcript_delete_participant"]; + options?: never; + head?: never; + /** Transcript Update Participant */ + patch: operations["v1_transcript_update_participant"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/assign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Assign Speaker */ + patch: operations["v1_transcript_assign_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Merge Speaker */ + patch: operations["v1_transcript_merge_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Upload */ + post: operations["v1_transcript_record_upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/download/zip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Download Zip */ + get: operations["v1_transcript_download_zip"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/video/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Video Url */ + get: operations["v1_transcript_get_video_url"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Transcript WebSocket event schema + * @description Stub exposing the discriminated union of all transcript-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path. + */ + get: operations["v1_transcript_get_websocket_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/webrtc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Webrtc */ + post: operations["v1_transcript_record_webrtc"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/process": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Process */ + post: operations["v1_transcript_process"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** User Me */ + get: operations["v1_user_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/user/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Api Keys */ + get: operations["v1_list_api_keys"]; + put?: never; + /** Create Api Key */ + post: operations["v1_create_api_key"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/user/api-keys/{key_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Api Key */ + delete: operations["v1_delete_api_key"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * User WebSocket event schema + * @description Stub exposing the discriminated union of all user-level WS events for OpenAPI type generation. Real events are delivered over the WebSocket at the same path. + */ + get: operations["v1_user_get_websocket_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Config */ + get: operations["v1_get_config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Streams + * @description Get all Zulip streams. + */ + get: operations["v1_zulip_get_streams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams/{stream_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Topics + * @description Get all topics for a specific Zulip stream. + */ + get: operations["v1_zulip_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/whereby": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Whereby Webhook */ + post: operations["v1_whereby_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/daily/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Webhook + * @description Handle Daily webhook events. + * + * Example webhook payload: + * { + * "version": "1.0.0", + * "type": "recording.ready-to-download", + * "id": "rec-rtd-c3df927c-f738-4471-a2b7-066fa7e95a6b-1692124192", + * "payload": { + * "recording_id": "08fa0b24-9220-44c5-846c-3f116cf8e738", + * "room_name": "Xcm97xRZ08b2dePKb78g", + * "start_ts": 1692124183, + * "status": "finished", + * "max_participants": 1, + * "duration": 9, + * "share_token": "ntDCL5k98Ulq", #gitleaks:allow + * "s3_key": "api-test-1j8fizhzd30c/Xcm97xRZ08b2dePKb78g/1692124183028" + * }, + * "event_ts": 1692124192 + * } + * + * Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook + * state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py + */ + post: operations["v1_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login */ + post: operations["v1_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AddEmailRecipientRequest */ + AddEmailRecipientRequest: { + /** + * Email + * Format: email + */ + email: string; + }; + /** ApiKeyResponse */ + ApiKeyResponse: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * User Id + * @description A non-empty string + */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** AudioWaveform */ + AudioWaveform: { + /** Data */ + data: number[]; + }; + /** Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post */ + Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post: { + /** Chunk */ + chunk: string; + }; + /** CalendarEventResponse */ + CalendarEventResponse: { + /** Id */ + id: string; + /** Room Id */ + room_id: string; + /** Ics Uid */ + ics_uid: string; + /** Title */ + title?: string | null; + /** Description */ + description?: string | null; + /** + * Start Time + * Format: date-time + */ + start_time: string; + /** + * End Time + * Format: date-time + */ + end_time: string; + /** Attendees */ + attendees?: + | { + [key: string]: unknown; + }[] + | null; + /** Location */ + location?: string | null; + /** + * Last Synced + * Format: date-time + */ + last_synced: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** ConfigResponse */ + ConfigResponse: { + /** Zulip Enabled */ + zulip_enabled: boolean; + /** Email Enabled */ + email_enabled: boolean; + }; + /** CreateApiKeyRequest */ + CreateApiKeyRequest: { + /** Name */ + name?: string | null; + }; + /** CreateApiKeyResponse */ + CreateApiKeyResponse: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * User Id + * @description A non-empty string + */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Key + * @description A non-empty string + */ + key: string; + }; + /** CreateParticipant */ + CreateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name: string; + }; + /** CreateRoom */ + CreateRoom: { + /** Name */ + name: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Webhook Url */ + webhook_url: string; + /** Webhook Secret */ + webhook_secret: string; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** + * Platform + * @enum {string} + */ + platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; + /** Email Transcript To */ + email_transcript_to?: string | null; + }; + /** CreateRoomMeeting */ + CreateRoomMeeting: { + /** + * Allow Duplicated + * @default false + */ + allow_duplicated: boolean | null; + }; + /** CreateTranscript */ + CreateTranscript: { + /** Name */ + name: string; + /** + * Source Language + * @default en + */ + source_language: string; + /** + * Target Language + * @default en + */ + target_language: string; + source_kind?: components["schemas"]["SourceKind"] | null; + }; + /** DeletionStatus */ + DeletionStatus: { + /** Status */ + status: string; + }; + /** GetTranscriptMinimal */ + GetTranscriptMinimal: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + }; + /** GetTranscriptSegmentTopic */ + GetTranscriptSegmentTopic: { + /** Text */ + text: string; + /** Start */ + start: number; + /** Speaker */ + speaker: number; + }; + /** GetTranscriptTopic */ + GetTranscriptTopic: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + }; + /** GetTranscriptTopicWithWords */ + GetTranscriptTopicWithWords: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words + * @default [] + */ + words: components["schemas"]["Word"][]; + }; + /** GetTranscriptTopicWithWordsPerSpeaker */ + GetTranscriptTopicWithWordsPerSpeaker: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words Per Speaker + * @default [] + */ + words_per_speaker: components["schemas"]["SpeakerWords"][]; + }; + /** + * GetTranscriptWithJSON + * @description Transcript response as structured JSON segments. + * + * Format: Array of segment objects with speaker info, text, and timing. + * Example: + * [ + * { + * "speaker": 0, + * "speaker_name": "John Smith", + * "text": "Hello everyone", + * "start": 0.0, + * "end": 5.0 + * } + * ] + */ + GetTranscriptWithJSON: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + /** Participants */ + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "json"; + /** Transcript */ + transcript: components["schemas"]["TranscriptSegment"][]; + }; + /** GetTranscriptWithParticipants */ + GetTranscriptWithParticipants: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + /** Participants */ + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; + }; + /** + * GetTranscriptWithText + * @description Transcript response with plain text format. + * + * Format: Speaker names followed by their dialogue, one line per segment. + * Example: + * John Smith: Hello everyone + * Jane Doe: Hi there + */ + GetTranscriptWithText: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + /** Participants */ + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "text"; + /** Transcript */ + transcript: string; + }; + /** + * GetTranscriptWithTextTimestamped + * @description Transcript response with timestamped text format. + * + * Format: [MM:SS] timestamp prefix before each speaker and dialogue. + * Example: + * [00:00] John Smith: Hello everyone + * [00:05] Jane Doe: Hi there + */ + GetTranscriptWithTextTimestamped: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + /** Participants */ + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "text-timestamped"; + /** Transcript */ + transcript: string; + }; + /** + * GetTranscriptWithWebVTTNamed + * @description Transcript response in WebVTT subtitle format with participant names. + * + * Format: Standard WebVTT with voice tags using participant names. + * Example: + * WEBVTT + * + * 00:00:00.000 --> 00:00:05.000 + * Hello everyone + */ + GetTranscriptWithWebVTTNamed: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Change Seq */ + change_seq?: number | null; + /** + * Has Cloud Video + * @default false + */ + has_cloud_video: boolean; + /** Cloud Video Duration */ + cloud_video_duration?: number | null; + /** Participants */ + participants: + | components["schemas"]["TranscriptParticipantWithEmail"][] + | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "webvtt-named"; + /** Transcript */ + transcript: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** ICSStatus */ + ICSStatus: { + /** + * Status + * @enum {string} + */ + status: "enabled" | "disabled"; + /** Last Sync */ + last_sync?: string | null; + /** Next Sync */ + next_sync?: string | null; + /** Last Etag */ + last_etag?: string | null; + /** + * Events Count + * @default 0 + */ + events_count: number; + }; + /** ICSSyncResult */ + ICSSyncResult: { + status: components["schemas"]["SyncStatus"]; + /** Hash */ + hash?: string | null; + /** + * Events Found + * @default 0 + */ + events_found: number; + /** + * Total Events + * @default 0 + */ + total_events: number; + /** + * Events Created + * @default 0 + */ + events_created: number; + /** + * Events Updated + * @default 0 + */ + events_updated: number; + /** + * Events Deleted + * @default 0 + */ + events_deleted: number; + /** Error */ + error?: string | null; + /** Reason */ + reason?: string | null; + }; + /** LoginRequest */ + LoginRequest: { + /** Email */ + email: string; + /** Password */ + password: string; + }; + /** LoginResponse */ + LoginResponse: { + /** Access Token */ + access_token: string; + /** + * Token Type + * @default bearer + */ + token_type: string; + /** Expires In */ + expires_in: number; + }; + /** Meeting */ + Meeting: { + /** Id */ + id: string; + /** Room Name */ + room_name: string; + /** Room Url */ + room_url: string; + /** Host Room Url */ + host_room_url: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** + * Is Locked + * @default false + */ + is_locked: boolean; + /** + * Room Mode + * @default normal + * @enum {string} + */ + room_mode: "normal" | "group"; + /** + * Recording Type + * @default cloud + * @enum {string} + */ + recording_type: "none" | "local" | "cloud"; + /** + * Recording Trigger + * @default automatic-2nd-participant + * @enum {string} + */ + recording_trigger: + | "none" + | "prompt" + | "automatic" + | "automatic-2nd-participant"; + /** + * Num Clients + * @default 0 + */ + num_clients: number; + /** + * Is Active + * @default true + */ + is_active: boolean; + /** Calendar Event Id */ + calendar_event_id?: string | null; + /** Calendar Metadata */ + calendar_metadata?: { + [key: string]: unknown; + } | null; + /** + * Platform + * @enum {string} + */ + platform: "whereby" | "daily"; + /** Daily Composed Video S3 Key */ + daily_composed_video_s3_key?: string | null; + /** Daily Composed Video Duration */ + daily_composed_video_duration?: number | null; + }; + /** MeetingConsentRequest */ + MeetingConsentRequest: { + /** Consent Given */ + consent_given: boolean; + }; + /** Page[GetTranscriptMinimal] */ + Page_GetTranscriptMinimal_: { + /** Items */ + items: components["schemas"]["GetTranscriptMinimal"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Size */ + size: number; + /** Pages */ + pages: number; + }; + /** Page[RoomDetails] */ + Page_RoomDetails_: { + /** Items */ + items: components["schemas"]["RoomDetails"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Size */ + size: number; + /** Pages */ + pages: number; + }; + /** Participant */ + Participant: { + /** Id */ + id: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + }; + /** ProcessStatus */ + ProcessStatus: { + /** Status */ + status: string; + }; + /** Room */ + Room: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; + /** + * Platform + * @enum {string} + */ + platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; + /** Email Transcript To */ + email_transcript_to?: string | null; + }; + /** RoomDetails */ + RoomDetails: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; + /** + * Platform + * @enum {string} + */ + platform: "whereby" | "daily"; + /** + * Skip Consent + * @default false + */ + skip_consent: boolean; + /** Email Transcript To */ + email_transcript_to?: string | null; + /** Webhook Url */ + webhook_url: string | null; + /** Webhook Secret */ + webhook_secret: string | null; + }; + /** RtcOffer */ + RtcOffer: { + /** Sdp */ + sdp: string; + /** Type */ + type: string; + }; + /** SearchResponse */ + SearchResponse: { + /** Results */ + results: components["schemas"]["SearchResult"][]; + /** + * Total + * @description Total number of search results + */ + total: number; + /** Query */ + query?: string | null; + /** + * Limit + * @description Results per page + */ + limit: number; + /** + * Offset + * @description Number of results to skip + */ + offset: number; + }; + /** + * SearchResult + * @description Public search result model with computed fields. + */ + SearchResult: { + /** Id */ + id: string; + /** Title */ + title?: string | null; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Created At */ + created_at: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Rank */ + rank: number; + /** + * Duration + * @description Duration in seconds + */ + duration: number | null; + /** + * Search Snippets + * @description Text snippets around search matches + */ + search_snippets: string[]; + /** + * Total Match Count + * @description Total number of matches found in the transcript + * @default 0 + */ + total_match_count: number; + /** Change Seq */ + change_seq?: number | null; + }; + /** SendEmailRequest */ + SendEmailRequest: { + /** Email */ + email: string; + }; + /** SendEmailResponse */ + SendEmailResponse: { + /** Sent */ + sent: number; + }; + /** + * SourceKind + * @enum {string} + */ + SourceKind: "room" | "live" | "file"; + /** SpeakerAssignment */ + SpeakerAssignment: { + /** Speaker */ + speaker?: number | null; + /** Participant */ + participant?: string | null; + /** Timestamp From */ + timestamp_from: number; + /** Timestamp To */ + timestamp_to: number; + }; + /** SpeakerAssignmentStatus */ + SpeakerAssignmentStatus: { + /** Status */ + status: string; + }; + /** SpeakerMerge */ + SpeakerMerge: { + /** Speaker From */ + speaker_from: number; + /** Speaker To */ + speaker_to: number; + }; + /** SpeakerWords */ + SpeakerWords: { + /** Speaker */ + speaker: number; + /** Words */ + words: components["schemas"]["Word"][]; + }; + /** StartRecordingRequest */ + StartRecordingRequest: { + /** + * Type + * @enum {string} + */ + type: "cloud" | "raw-tracks"; + /** + * Instanceid + * Format: uuid + */ + instanceId: string; + }; + /** Stream */ + Stream: { + /** Stream Id */ + stream_id: number; + /** Name */ + name: string; + }; + /** + * SyncStatus + * @enum {string} + */ + SyncStatus: "success" | "unchanged" | "error" | "skipped"; + /** Topic */ + Topic: { + /** Name */ + name: string; + }; + /** TranscriptActionItems */ + TranscriptActionItems: { + /** Action Items */ + action_items: { + [key: string]: unknown; + }; + }; + /** TranscriptDuration */ + TranscriptDuration: { + /** Duration */ + duration: number; + }; + /** TranscriptFinalLongSummary */ + TranscriptFinalLongSummary: { + /** Long Summary */ + long_summary: string; + }; + /** TranscriptFinalShortSummary */ + TranscriptFinalShortSummary: { + /** Short Summary */ + short_summary: string; + }; + /** TranscriptFinalTitle */ + TranscriptFinalTitle: { + /** Title */ + title: string; + }; + /** TranscriptParticipant */ + TranscriptParticipant: { + /** Id */ + id?: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + /** User Id */ + user_id?: string | null; + }; + /** TranscriptParticipantWithEmail */ + TranscriptParticipantWithEmail: { + /** Id */ + id?: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + /** User Id */ + user_id?: string | null; + /** Email */ + email?: string | null; + }; + /** + * TranscriptSegment + * @description A single transcript segment with speaker and timing information. + */ + TranscriptSegment: { + /** Speaker */ + speaker: number; + /** Speaker Name */ + speaker_name: string; + /** Text */ + text: string; + /** Start */ + start: number; + /** End */ + end: number; + }; + /** TranscriptText */ + TranscriptText: { + /** Text */ + text: string; + /** Translation */ + translation: string | null; + }; + /** TranscriptWaveform */ + TranscriptWaveform: { + /** Waveform */ + waveform: number[]; + }; + /** TranscriptWsActionItems */ + TranscriptWsActionItems: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "ACTION_ITEMS"; + data: components["schemas"]["TranscriptActionItems"]; + }; + /** TranscriptWsDuration */ + TranscriptWsDuration: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "DURATION"; + data: components["schemas"]["TranscriptDuration"]; + }; + /** TranscriptWsFinalLongSummary */ + TranscriptWsFinalLongSummary: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "FINAL_LONG_SUMMARY"; + data: components["schemas"]["TranscriptFinalLongSummary"]; + }; + /** TranscriptWsFinalShortSummary */ + TranscriptWsFinalShortSummary: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "FINAL_SHORT_SUMMARY"; + data: components["schemas"]["TranscriptFinalShortSummary"]; + }; + /** TranscriptWsFinalTitle */ + TranscriptWsFinalTitle: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "FINAL_TITLE"; + data: components["schemas"]["TranscriptFinalTitle"]; + }; + /** TranscriptWsStatus */ + TranscriptWsStatus: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "STATUS"; + data: components["schemas"]["TranscriptWsStatusData"]; + }; + /** TranscriptWsStatusData */ + TranscriptWsStatusData: { + /** + * Value + * @enum {string} + */ + value: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + }; + /** TranscriptWsTopic */ + TranscriptWsTopic: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TOPIC"; + data: components["schemas"]["GetTranscriptTopic"]; + }; + /** TranscriptWsTranscript */ + TranscriptWsTranscript: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT"; + data: components["schemas"]["TranscriptText"]; + }; + /** TranscriptWsWaveform */ + TranscriptWsWaveform: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "WAVEFORM"; + data: components["schemas"]["TranscriptWaveform"]; + }; + /** UpdateParticipant */ + UpdateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name?: string | null; + }; + /** UpdateRoom */ + UpdateRoom: { + /** Name */ + name?: string | null; + /** Zulip Auto Post */ + zulip_auto_post?: boolean | null; + /** Zulip Stream */ + zulip_stream?: string | null; + /** Zulip Topic */ + zulip_topic?: string | null; + /** Is Locked */ + is_locked?: boolean | null; + /** Room Mode */ + room_mode?: string | null; + /** Recording Type */ + recording_type?: string | null; + /** Recording Trigger */ + recording_trigger?: string | null; + /** Is Shared */ + is_shared?: boolean | null; + /** Webhook Url */ + webhook_url?: string | null; + /** Webhook Secret */ + webhook_secret?: string | null; + /** Ics Url */ + ics_url?: string | null; + /** Ics Fetch Interval */ + ics_fetch_interval?: number | null; + /** Ics Enabled */ + ics_enabled?: boolean | null; + /** Platform */ + platform?: ("whereby" | "daily") | null; + /** Skip Consent */ + skip_consent?: boolean | null; + /** Email Transcript To */ + email_transcript_to?: string | null; + }; + /** UpdateTranscript */ + UpdateTranscript: { + /** Name */ + name?: string | null; + /** Locked */ + locked?: boolean | null; + /** Title */ + title?: string | null; + /** Short Summary */ + short_summary?: string | null; + /** Long Summary */ + long_summary?: string | null; + /** Share Mode */ + share_mode?: ("public" | "semi-private" | "private") | null; + /** Participants */ + participants?: components["schemas"]["TranscriptParticipant"][] | null; + /** Reviewed */ + reviewed?: boolean | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + }; + /** UserInfo */ + UserInfo: { + /** Sub */ + sub: string; + /** Email */ + email: string | null; + }; + /** UserTranscriptCreatedData */ + UserTranscriptCreatedData: { + /** + * Id + * @description A non-empty string + */ + id: string; + }; + /** UserTranscriptDeletedData */ + UserTranscriptDeletedData: { + /** + * Id + * @description A non-empty string + */ + id: string; + }; + /** UserTranscriptDurationData */ + UserTranscriptDurationData: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** Duration */ + duration: number; + }; + /** UserTranscriptFinalTitleData */ + UserTranscriptFinalTitleData: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * Title + * @description A non-empty string + */ + title: string; + }; + /** UserTranscriptStatusData */ + UserTranscriptStatusData: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * Value + * @enum {string} + */ + value: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + }; + /** UserWsTranscriptCreated */ + UserWsTranscriptCreated: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_CREATED"; + data: components["schemas"]["UserTranscriptCreatedData"]; + }; + /** UserWsTranscriptDeleted */ + UserWsTranscriptDeleted: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_DELETED"; + data: components["schemas"]["UserTranscriptDeletedData"]; + }; + /** UserWsTranscriptDuration */ + UserWsTranscriptDuration: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_DURATION"; + data: components["schemas"]["UserTranscriptDurationData"]; + }; + /** UserWsTranscriptFinalTitle */ + UserWsTranscriptFinalTitle: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_FINAL_TITLE"; + data: components["schemas"]["UserTranscriptFinalTitleData"]; + }; + /** UserWsTranscriptStatus */ + UserWsTranscriptStatus: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_STATUS"; + data: components["schemas"]["UserTranscriptStatusData"]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** VideoUrlResponse */ + VideoUrlResponse: { + /** Url */ + url: string; + /** Duration */ + duration?: number | null; + /** + * Content Type + * @default video/mp4 + */ + content_type: string; + }; + /** WebhookTestResult */ + WebhookTestResult: { + /** Success */ + success: boolean; + /** + * Message + * @default + */ + message: string; + /** + * Error + * @default + */ + error: string; + /** Status Code */ + status_code?: number | null; + /** Response Preview */ + response_preview?: string | null; + }; + /** WherebyWebhookEvent */ + WherebyWebhookEvent: { + /** Apiversion */ + apiVersion: string; + /** Id */ + id: string; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** Type */ + type: string; + /** Data */ + data: { + [key: string]: unknown; + }; + }; + /** Word */ + Word: { + /** Text */ + text: string; + /** + * Start + * @description Time in seconds with float part + */ + start: number; + /** + * End + * @description Time in seconds with float part + */ + end: number; + /** + * Speaker + * @default 0 + */ + speaker: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + metrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + v1_meeting_audio_consent: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeetingConsentRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_meeting_deactivate: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_start_recording: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StartRecordingRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_add_email_recipient: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddEmailRecipientRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_RoomDetails_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_delete: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_update: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get_by_name: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoomMeeting"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_test_webhook: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookTestResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_sync_ics: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSSyncResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_ics_status: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_upcoming_meetings: { + parameters: { + query?: { + minutes_ahead?: number; + }; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_active_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_join_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_list: { + parameters: { + query?: { + source_kind?: components["schemas"]["SourceKind"] | null; + room_id?: string | null; + search_term?: string | null; + change_seq_from?: number | null; + sort_by?: ("created_at" | "change_seq") | null; + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_GetTranscriptMinimal_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptWithParticipants"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_search: { + parameters: { + query: { + /** @description Search query text */ + q: string; + /** @description Results per page */ + limit?: number; + /** @description Number of results to skip */ + offset?: number; + room_id?: string | null; + source_kind?: components["schemas"]["SourceKind"] | null; + /** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */ + from?: string | null; + /** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */ + to?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get: { + parameters: { + query?: { + transcript_format?: + | "text" + | "text-timestamped" + | "webvtt-named" + | "json"; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["GetTranscriptWithText"] + | components["schemas"]["GetTranscriptWithTextTimestamped"] + | components["schemas"]["GetTranscriptWithWebVTTNamed"] + | components["schemas"]["GetTranscriptWithJSON"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptWithParticipants"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWords"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words_per_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + topic_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_post_to_zulip: { + parameters: { + query: { + stream: string; + topic: string; + include_topics: boolean; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_send_email: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendEmailRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendEmailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_head_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_waveform: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AudioWaveform"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participants: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_add_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_assign_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerAssignment"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_merge_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerMerge"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_upload: { + parameters: { + query: { + chunk_number: number; + total_chunks: number; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_download_zip: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_video_url: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VideoUrlResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_websocket_events: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["TranscriptWsTranscript"] + | components["schemas"]["TranscriptWsTopic"] + | components["schemas"]["TranscriptWsStatus"] + | components["schemas"]["TranscriptWsFinalTitle"] + | components["schemas"]["TranscriptWsFinalLongSummary"] + | components["schemas"]["TranscriptWsFinalShortSummary"] + | components["schemas"]["TranscriptWsActionItems"] + | components["schemas"]["TranscriptWsDuration"] + | components["schemas"]["TranscriptWsWaveform"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_webrtc: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RtcOffer"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_process: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProcessStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_user_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserInfo"] | null; + }; + }; + }; + }; + v1_list_api_keys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiKeyResponse"][]; + }; + }; + }; + }; + v1_create_api_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateApiKeyResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_delete_api_key: { + parameters: { + query?: never; + header?: never; + path: { + key_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_user_get_websocket_events: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["UserWsTranscriptCreated"] + | components["schemas"]["UserWsTranscriptDeleted"] + | components["schemas"]["UserWsTranscriptStatus"] + | components["schemas"]["UserWsTranscriptFinalTitle"] + | components["schemas"]["UserWsTranscriptDuration"]; + }; + }; + }; + }; + v1_get_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + }; + }; + v1_zulip_get_streams: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stream"][]; + }; + }; + }; + }; + v1_zulip_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + stream_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Topic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_whereby_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WherebyWebhookEvent"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + v1_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/www/appv2/src/lib/sentry.ts b/www/appv2/src/lib/sentry.ts new file mode 100644 index 00000000..9757694c --- /dev/null +++ b/www/appv2/src/lib/sentry.ts @@ -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 }; diff --git a/www/appv2/src/lib/supportedLanguages.ts b/www/appv2/src/lib/supportedLanguages.ts new file mode 100644 index 00000000..6ddc6f14 --- /dev/null +++ b/www/appv2/src/lib/supportedLanguages.ts @@ -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 }; diff --git a/www/appv2/src/lib/timeUtils.ts b/www/appv2/src/lib/timeUtils.ts new file mode 100644 index 00000000..db8a8152 --- /dev/null +++ b/www/appv2/src/lib/timeUtils.ts @@ -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`; +}; diff --git a/www/appv2/src/lib/types.ts b/www/appv2/src/lib/types.ts new file mode 100644 index 00000000..eb96b43f --- /dev/null +++ b/www/appv2/src/lib/types.ts @@ -0,0 +1,13 @@ +import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils"; + +export type Mutable = { + -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"; diff --git a/www/appv2/src/lib/utils.ts b/www/appv2/src/lib/utils.ts new file mode 100644 index 00000000..7b989eb9 --- /dev/null +++ b/www/appv2/src/lib/utils.ts @@ -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 = ( + 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 = ( + 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, + ); diff --git a/www/appv2/src/lib/wherebyClient.ts b/www/appv2/src/lib/wherebyClient.ts new file mode 100644 index 00000000..9d644c8e --- /dev/null +++ b/www/appv2/src/lib/wherebyClient.ts @@ -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, +) => + // host_room_url possible '' atm + meeting.host_room_url || meeting.room_url; diff --git a/www/appv2/src/main.tsx b/www/appv2/src/main.tsx new file mode 100644 index 00000000..4f118884 --- /dev/null +++ b/www/appv2/src/main.tsx @@ -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( + + + + + , +); diff --git a/www/appv2/src/pages/AboutPage.tsx b/www/appv2/src/pages/AboutPage.tsx new file mode 100644 index 00000000..6f18b107 --- /dev/null +++ b/www/appv2/src/pages/AboutPage.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Info } from "lucide-react"; + +export default function AboutPage() { + return ( +
+
+
+ +

About Us

+
+ +
+

+ 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. +

+ +
+

FAQs

+ +
+

1. How does it work?

+

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.

+
+ +
+

2. What makes Reflector different?

+

Monadical prioritizes safeguarding your data. Reflector operates exclusively on your infrastructure, ensuring guaranteed security.

+
+ +
+

3. Are there any industry-specific use cases?

+

Absolutely! We have two custom deployments pre-built:

+
    +
  • Reflector Media: Ideal for meetings, providing real-time notes and topic summaries.
  • +
  • Projector Reflector: Suited for larger events, offering live topic summaries, translations, and agenda tracking.
  • +
+
+ +
+

4. Who’s behind Reflector?

+

Monadical is a cohesive and effective team that can connect seamlessly into your workflows, and we are ready to integrate Reflector’s building blocks into your custom tools. We’re committed to building software that outlasts us 🐙.

+
+
+ + +
+
+
+ ); +} diff --git a/www/appv2/src/pages/LoginPage.tsx b/www/appv2/src/pages/LoginPage.tsx new file mode 100644 index 00000000..384edecc --- /dev/null +++ b/www/appv2/src/pages/LoginPage.tsx @@ -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(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 ( +
+ {/* Top Navigation */} +
+
+ Reflector Logo + + Reflector + +
+
+ + +
+
+ + {/* Main Content */} +
+
+ {/* Left Column: Marketing Copy */} +
+

+ Welcome to + Reflector +

+ +

+ Access a curated digital environment designed for intellectual + authority and archival depth. Manage your collections with the + precision of a modern curator. +

+ +
+ +
+ +
+

+ "The Digital Curator prioritizes warmth, intentionality, and + authority in every interaction." +

+ + Privacy policy + +
+
+ + {/* Right Column: Login Card */} +
+
+
+ +
+ +

+ Secure Access +

+ +

+ Enter the archive to view your curated workspace and historical + logs. +

+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Credentials Form */} +
+ 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" + /> + 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" + /> + +
+ + {/* Divider */} +
+
+ + or + +
+
+ + {/* SSO Button */} + + +

+ Authorized Personnel Only +

+
+ + {/* Floating Editorial Detail */} +
+ + + Curated Experience Engine v6.9 + +
+
+
+
+ + {/* Footer */} + +
+ ); +} diff --git a/www/appv2/src/pages/PrivacyPage.tsx b/www/appv2/src/pages/PrivacyPage.tsx new file mode 100644 index 00000000..2f60a97c --- /dev/null +++ b/www/appv2/src/pages/PrivacyPage.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { ShieldAlert } from "lucide-react"; + +export default function PrivacyPage() { + return ( +
+
+
+ +

Privacy Policy

+
+

Last updated on September 22, 2023

+ +
+
    +
  • + Recording Consent +

    By using Reflector, you grant us permission to record your interactions for the purpose of showcasing Reflector's capabilities during the All In AI conference.

    +
  • + +
  • + Data Access +

    You will have convenient access to your recorded sessions and transcriptions via a unique URL, which remains active for a period of seven days. After this time, your recordings and transcripts will be permanently deleted.

    +
  • + +
  • + Data Confidentiality +

    Rest assured that none of your audio data will be shared with third parties.

    +
  • +
+ +
+

+ Questions or Concerns: If you have any questions or concerns regarding your data, please feel free to reach out to us at{" "} + + reflector@monadical.com + +

+
+
+
+
+ ); +} diff --git a/www/appv2/src/pages/RoomMeetingPage.tsx b/www/appv2/src/pages/RoomMeetingPage.tsx new file mode 100644 index 00000000..ae4f0f35 --- /dev/null +++ b/www/appv2/src/pages/RoomMeetingPage.tsx @@ -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 ( +
+ +
+ ); +} + +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
Missing Room Parameter
; + } + + 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 ; + } + + if (!room && !isLoading) { + return ( +
+

Room not found or unauthorized.

+
+ ); + } + + if (room?.ics_enabled && !pageMeetingId) { + return ( + + ); + } + + if (errors.length > 0) { + return ( +
+ {errors.map((error, i) => ( +

+ {printApiError(error)} +

+ ))} +
+ ); + } + + if (!meeting) { + return ; + } + + const platform = meeting.platform; + + if (!platform) { + return ( +
+

Meeting platform not configured properly.

+
+ ); + } + + switch (platform) { + case 'daily': + return ; + case 'whereby': + return ; + default: { + return ( +
+

Unknown platform: {platform}

+
+ ); + } + } +} diff --git a/www/appv2/src/pages/RoomsPage.tsx b/www/appv2/src/pages/RoomsPage.tsx new file mode 100644 index 00000000..9cc1cf6e --- /dev/null +++ b/www/appv2/src/pages/RoomsPage.tsx @@ -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
; + } + + if (activeMeetings.length > 0) { + const meeting = activeMeetings[0]; + const title = String(meeting.calendar_metadata?.['title'] || "Active Meeting"); + return ( +
+ {title} + {meeting.num_clients} participants +
+ ); + } + + 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 ( +
+ + {diffMinutes < MEETING_DEFAULT_TIME_MINUTES ? `In ${diffMinutes}m` : "Upcoming"} + + + {event.title || "Scheduled Meeting"} + + + {startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" })} + +
+ ); + } + + return No meetings; +} + +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(null); + const [activeTab, setActiveTab] = useState<'my' | 'shared'>('my'); + const [copiedRoom, setCopiedRoom] = useState(null); + const [syncingRooms, setSyncingRooms] = useState>(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 ( +
+
+ + {/* Page Header */} +
+

Rooms

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Rooms Table / Empty State */} +
+ {isLoading ? ( +
+
+

Retrieving rooms...

+
+ ) : isError ? ( +
+ +

Archive connection failed. Please try again.

+
+ ) : filteredRooms.length === 0 ? ( +
+
+ +
+

+ {activeTab === 'my' ? "You haven't curated any rooms yet. Begin a new archival context." : "No shared rooms available in your workspace."} +

+ {activeTab === 'my' && ( + + )} +
+ ) : ( +
+ + + + + + + + + + + + + {filteredRooms.map((room) => ( + + + + + + + + + + + + + + ))} + +
Room NameCurrent MeetingZulipRoom SizeRecordingActions
+
+ navigate(`/rooms/${room.name}`)} + className="font-serif text-[1rem] font-bold text-on-surface hover:text-primary transition-colors cursor-pointer" + > + {room.name} + +
+
+ + + + {getZulipDisplay(room.zulip_auto_post, room.zulip_stream || '', room.zulip_topic || '')} + + + + {getRoomModeDisplay(room.room_mode || '')} + + + + {getRecordingDisplay(room.recording_type || '', room.recording_trigger || '')} + + +
+ {room.ics_enabled && ( + + )} + + + + + {!room.is_shared && ( + + )} +
+
+
+ )} +
+ +
+ + {/* Footer */} + + + setIsAddRoomModalOpen(false)} + editRoomId={editRoomId} + /> +
+ ); +} diff --git a/www/appv2/src/pages/SettingsPage.tsx b/www/appv2/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..9cba6926 --- /dev/null +++ b/www/appv2/src/pages/SettingsPage.tsx @@ -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(null); + + const { register, handleSubmit, reset, formState: { errors } } = useForm(); + + 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 ( +
+ {/* Content Area */} +
+ + {/* Page Header */} +
+

API Keys

+

+ Manage your API keys to authenticate with the Editorial Archive API. Keep these keys secure and never share them publicly. +

+
+ + {/* Create New API Key Card */} +
+ {!isCreating ? ( +
+
+

Create New API Key

+

+ Generate a new secret token to access our archival endpoints. +

+
+ +
+ ) : ( +
+
+ + + {errors.keyName &&

Name is required (min 3 characters).

} +
+
+ + +
+
+ )} +
+ + {newKey && ( +
+
+
+ +
+
+

+ API Key Created: {newKey.name} +

+

+ Make sure to copy your personal access token now. You won't be able to see it again! +

+
+
+ {newKey.key} +
+ +
+
+ +
+
+ )} + + {/* Your API Keys Section */} +
+

Your API Keys

+ + {apiKeys.length === 0 ? ( + /* Empty State */ +
+ +

No API keys yet.

+

+ You haven't generated any keys yet. Create one above to start curating your archive via API. +

+
+ ) : ( + /* Table View */ +
+ + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + ))} + +
NameIdCreatedActions
{key.name} + + ...{key.id.slice(-6)} + + {new Date(key.created_at).toLocaleDateString()} + +
+
+ )} +
+ + {/* Bottom Info Cards Row */} +
+ + {/* Card 1 — Security Best Practices */} +
+ +

Security Best Practices

+
    +
  • + + Never commit your API keys to version control systems like GitHub. +
  • +
  • + + Rotate your keys every 90 days to minimize risk of exposure. +
  • +
  • + + Use environment variables to store your keys in production. +
  • +
+
+ + {/* Card 2 — API Documentation */} +
+ +

API Documentation

+

+ Learn how to integrate the Editorial Archive into your workflow with our comprehensive guides. +

+ + View Documentation + +
+ +
+ +
+ + {/* Footer */} + +
+ ); +} diff --git a/www/appv2/src/pages/SingleTranscriptionPage.tsx b/www/appv2/src/pages/SingleTranscriptionPage.tsx new file mode 100644 index 00000000..bbf860f2 --- /dev/null +++ b/www/appv2/src/pages/SingleTranscriptionPage.tsx @@ -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 ( +
+
Loading Transcript Data...
+
+ ); + } + + if (error || !transcript) { + return ( +
+
Error loading transcript. It may not exist.
+
+ ); + } + + const status = transcript.status; + + // View routing based on status + if (status === 'processing' || status === 'uploaded') { + return ; + } + + if (status === 'recording') { + return ; + } + + if (status === 'idle') { + if (transcript.source_kind === 'file') { + return ; + } else { + return ; + } + } + + // If status is 'ended', we render the actual document + return ; +} + +// 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(null); + + const [expandedChapters, setExpandedChapters] = useState>({}); + 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)} + {text.slice(idx, idx + q.length)} + {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) => { + 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 ( +
+ {/* Native audio element — hidden, only mounted when audio is available */} + {!audioDeleted && ( +