Compare commits

...

15 Commits

Author SHA1 Message Date
opencode
47cb75ee39 feat: add background information field to room model
- Add background_information field to Room database table and model
- Create database migration for the new field
- Update API schemas (CreateRoom, UpdateRoom) to handle background_information
- Integrate room context into AI summarization prompts
- Add background_information field to frontend room form
- Update TypeScript types from regenerated OpenAPI spec

The background information will be used to provide context for AI-generated
summaries, helping create more appropriate and relevant meeting summaries.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-29 01:53:13 +00:00
db3beae5cd chore(main): release 0.4.0 (#510) 2025-07-25 19:09:57 -06:00
Igor Loskutov
03b9a18c1b fix: remove faulty import Meeting (#512)
* fix: remove faulty import Meeting

* fix: remove faulty import Meeting
2025-07-25 17:48:10 -04:00
Igor Loskutov
7e3027adb6 fix: room concurrency (theoretically) (#511)
* fix: room concurrency (theoretically)

* cleanup

* cleanup
2025-07-25 17:37:51 -04:00
Igor Loskutov
27b43d85ab feat: Diarization cli (#509)
* diarisation cli

* feat: s3 upload for modal diarisation cli call

* chore: cleanup

* chore: s3 cleanup improvement

* chore: lint

* chore: cleanup

* chore: cleanup

* chore: cleanup

* chore: cleanup
2025-07-25 16:24:06 -04:00
2289a1a231 chore(main): release 0.3.2 (#506) 2025-07-22 19:15:47 -06:00
d0e130eb13 fix: match font size for the filter sidebar (#507) 2025-07-22 14:59:23 -06:00
24fabe3e86 fix: whereby consent not displaying (#505) 2025-07-22 12:20:26 -06:00
6fedbbe63f chore(main): release 0.3.1 (#503) 2025-07-21 22:52:21 -06:00
b39175cdc9 fix: remove primary color for room action menu (#504) 2025-07-21 22:45:26 -06:00
2a2af5fff2 fix: remove fief out of the source code (#502)
* fix: remove fief out of the source code

* fix: remove corresponding test about migration
2025-07-21 21:09:05 -06:00
ad44492cae chore(main): release 0.3.0 (#501) 2025-07-21 19:14:15 -06:00
901a239952 feat: migrate from chakra 2 to chakra 3 (#500)
* feat: separate page into different component, greatly improving the loading and reactivity

* fix: various fixes

* feat: migrate to Chakra UI v3 - update theme, fix deprecated props

- Add whiteAlpha color palette with semantic tokens
- Update button recipe with fontWeight 600 and hover states
- Move Poppins font from theme to HTML tag className
- Fix deprecated props: isDisabled→disabled, align→alignItems/textAlign
- Remove button.css as styles are now handled by Chakra v3

* fix: complete Chakra UI v3 deprecated prop migrations

- Replace all isDisabled with disabled
- Replace all isChecked with checked
- Replace all isLoading with loading
- Replace all isOpen with open
- Replace all noOfLines with lineClamp
- Replace all align with alignItems on Flex/Stack components
- Replace all justify with justifyContent on Flex/Stack components
- Update temporary Select components to use new prop names
- Update REFACTOR2.md with completion status

* fix: add value prop to Menu.Item for proper hover states in Chakra v3

* fix: update browse page components for Chakra UI v3 compatibility

- Fix FilterSidebar status filter styling and prop usage
- Update Pagination component to use new Chakra v3 props and structure
- Refactor TranscriptTable to use modern Chakra patterns
- Clean up browse page layout and props
- Remove unused import from transcripts API view
- Enhance theme with additional semantic color tokens

* fix: polish browse page UI for Chakra v3

- Add rounded corners to FilterSidebar
- Adjust responsive breakpoints from md to lg for table/card view
- Add consistent font weights to table headers
- Improve card view typography and spacing
- Fix padding and margins for better mobile experience
- Remove unused table recipe from theme

* fix: padding

* fix: rework transcript page

* fix: more tidy layout for topic

* fix: share and privacy using chakra3 select

* fix: fix share and privacy select, now working, with closing dialog

* fix: complete Chakra UI v3 migration for share components and fix all TypeScript errors

- Refactor shareZulip.tsx to integrate modal content directly
- Replace react-select-search with Chakra UI v3 Select components using collection pattern
- Convert all Checkbox components to use v3 composable structure (Checkbox.Root, etc.)
- Fix Card components to use Card.Root and Card.Body
- Replace deprecated textColor prop with color prop
- Update Menu components to use v3 namespace pattern (Menu.Root, Menu.Trigger, etc.)
- Remove unused AlertDialog imports
- Fix useDisclosure hook changes (isOpen -> open)
- Replace UnorderedList with List.Root and ListItem with List.Item
- Fix Skeleton components by removing isLoaded prop and using conditional rendering
- Update Button variants to valid v3 options
- Fix Spinner props (remove thickness, speed, emptyColor)
- Update toast API to use custom toaster component
- Fix Progress components and FormControl to Field.Root
- Update Alert to use compound component pattern
- Remove shareModal.tsx file after integration

* fix: bring back topic list

* fix: normalize menu item

* fix: migrate rooms page to Chakra UI v3 pattern

- Updated layout to match browse page with Flex container and proper spacing
- Migrated add/edit room modal from custom HTML to Chakra UI v3 Dialog component
- Replaced all Select components with Chakra UI v3 Select using createListCollection
- Replaced FormControl/FormLabel/FormHelperText with Field.Root/Field.Label/Field.HelperText
- Removed inline styles and used Chakra props (mr={2} instead of style={{ marginRight: "8px" }})
- Fixed TypeScript interfaces removing OptionBase extension
- Fixed theme.ts accordion anatomy import issue

* refactor: convert rooms list to table view with responsive design

- Create RoomTable component for desktop view showing room details in columns
- Create RoomCards component for mobile/tablet responsive view
- Refactor RoomList to use table/card components based on screen size
- Display Zulip configuration, room size, and recording settings in table
- Remove unused RoomItem component
- Import Room type from API for proper typing

* refactor: extract RoomActionsMenu component to eliminate duplication

- Create RoomActionsMenu component for consistent room action menus
- Update RoomCards and RoomTable to use the new shared component
- Remove duplicated menu code from both components

* feat: add icons to TranscriptActionsMenu for consistency

- Add FaTrash icon for Delete action with red color
- Add FaArrowsRotate icon for Reprocess action
- Matches the pattern established in RoomActionsMenu

* refactor: update icons from Font Awesome to Lucide React

- Replace FaEllipsisVertical with LuMenu in menu triggers
- Replace FaLink with LuLink for copy URL buttons
- Replace FaPencil with LuPen for edit actions
- Replace FaTrash with LuTrash for delete actions
- Replace FaArrowsRotate with LuRotateCw for reprocess action
- Consistent icon library usage across all components

* refactor: little pass on the icons

* fix: lu icon

* fix: primary for button

* fix: recording page with mic selection

* fix: also fix duration

* fix: use combobox for share zulip

* fix: use proper theming for button, variant was not recognized

* fix: room actions menu

* fix: remove other variant primary left.
2025-07-21 16:16:12 -06:00
d77b5611f8 chore(main): release 0.2.1 (#499) 2025-07-17 20:19:56 -06:00
fc38345d65 fix: separate browsing page into different components, limit to 10 by default (#498)
* feat: limit the amount of transcripts to 10 by default

* feat: separate page into different component, greatly improving the
loading and reactivity

* fix: current implementation immediately invokes the onDelete and
onReprocess

From pr-agent-monadical: Suggestion: The current implementation
immediately invokes the onDelete and onReprocess functions when the
component renders, rather than when the menu items are clicked. This can
cause unexpected behavior and potential memory leaks. Use callback
functions that only execute when the menu items are actually clicked.
[possible issue, importance: 9]
2025-07-17 20:18:00 -06:00
114 changed files with 8639 additions and 6568 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ ngrok.log
restart-dev.sh
*.log
data/
www/REFACTOR.md
www/reload-frontend

View File

@@ -1,5 +1,48 @@
# Changelog
## [0.4.0](https://github.com/Monadical-SAS/reflector/compare/v0.3.2...v0.4.0) (2025-07-25)
### Features
* Diarization cli ([#509](https://github.com/Monadical-SAS/reflector/issues/509)) ([ffc8003](https://github.com/Monadical-SAS/reflector/commit/ffc8003e6dad236930a27d0fe3e2f2adfb793890))
### Bug Fixes
* remove faulty import Meeting ([#512](https://github.com/Monadical-SAS/reflector/issues/512)) ([0e68c79](https://github.com/Monadical-SAS/reflector/commit/0e68c798434e1b481f9482cc3a4702ea00365df4))
* room concurrency (theoretically) ([#511](https://github.com/Monadical-SAS/reflector/issues/511)) ([7bb3676](https://github.com/Monadical-SAS/reflector/commit/7bb367653afeb2778cff697a0eb217abf0b81b84))
## [0.3.2](https://github.com/Monadical-SAS/reflector/compare/v0.3.1...v0.3.2) (2025-07-22)
### Bug Fixes
* match font size for the filter sidebar ([#507](https://github.com/Monadical-SAS/reflector/issues/507)) ([4b8ba5d](https://github.com/Monadical-SAS/reflector/commit/4b8ba5db1733557e27b098ad3d1cdecadf97ae52))
* whereby consent not displaying ([#505](https://github.com/Monadical-SAS/reflector/issues/505)) ([1120552](https://github.com/Monadical-SAS/reflector/commit/1120552c2c83d084d3a39272ad49b6aeda1af98f))
## [0.3.1](https://github.com/Monadical-SAS/reflector/compare/v0.3.0...v0.3.1) (2025-07-22)
### Bug Fixes
* remove fief out of the source code ([#502](https://github.com/Monadical-SAS/reflector/issues/502)) ([890dd15](https://github.com/Monadical-SAS/reflector/commit/890dd15ba5a2be10dbb841e9aeb75d377885f4af))
* remove primary color for room action menu ([#504](https://github.com/Monadical-SAS/reflector/issues/504)) ([2e33f89](https://github.com/Monadical-SAS/reflector/commit/2e33f89c0f9e5fbaafa80e8d2ae9788450ea2f31))
## [0.3.0](https://github.com/Monadical-SAS/reflector/compare/v0.2.1...v0.3.0) (2025-07-21)
### Features
* migrate from chakra 2 to chakra 3 ([#500](https://github.com/Monadical-SAS/reflector/issues/500)) ([a858464](https://github.com/Monadical-SAS/reflector/commit/a858464c7a80e5497acf801d933bf04092f8b526))
## [0.2.1](https://github.com/Monadical-SAS/reflector/compare/v0.2.0...v0.2.1) (2025-07-18)
### Bug Fixes
* separate browsing page into different components, limit to 10 by default ([#498](https://github.com/Monadical-SAS/reflector/issues/498)) ([c752da6](https://github.com/Monadical-SAS/reflector/commit/c752da6b97c96318aff079a5b2a6eceadfbfcad1))
## [0.2.0](https://github.com/Monadical-SAS/reflector/compare/0.1.1...v0.2.0) (2025-07-17)

View File

@@ -146,7 +146,7 @@ All endpoints prefixed `/v1/`:
- `REDIS_URL` - Redis broker for Celery
- `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET` - Modal.com GPU processing
- `WHEREBY_API_KEY` - Video platform integration
- `REFLECTOR_AUTH_BACKEND` - Authentication method (none, fief, jwt)
- `REFLECTOR_AUTH_BACKEND` - Authentication method (none, jwt)
**Frontend** (`www/.env`):
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration

View File

@@ -6,11 +6,6 @@ LLM_BACKEND=modal
LLM_URL=https://monadical-sas--reflector-llm-web.modal.run
LLM_MODAL_API_KEY=***REMOVED***
AUTH_BACKEND=fief
AUTH_FIEF_URL=https://auth.reflector.media/reflector-local
AUTH_FIEF_CLIENT_ID=***REMOVED***
AUTH_FIEF_CLIENT_SECRET=<ask in zulip> <-----------------------------------------------------------------------------------------
TRANSLATE_URL=https://monadical-sas--reflector-translator-web.modal.run
ZEPHYR_LLM_URL=https://monadical-sas--reflector-llm-zephyr-web.modal.run
DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run

1
server/.gitignore vendored
View File

@@ -180,3 +180,4 @@ reflector.sqlite3
data/
dump.rdb

View File

@@ -7,11 +7,9 @@
## User authentication
## =======================================================
## Using fief (fief.dev)
AUTH_BACKEND=fief
AUTH_FIEF_URL=https://auth.reflector.media/reflector-local
AUTH_FIEF_CLIENT_ID=***REMOVED***
AUTH_FIEF_CLIENT_SECRET=<ask in zulip>
## Using jwt/authentik
AUTH_BACKEND=jwt
AUTH_JWT_AUDIENCE=
## =======================================================
## Transcription backend
@@ -88,4 +86,3 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
## Sentry DSN configuration
#SENTRY_DSN=

View File

@@ -0,0 +1,26 @@
"""add_room_background_information
Revision ID: 082fa608201c
Revises: b7df9609542c
Create Date: 2025-07-29 01:41:37.912195
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '082fa608201c'
down_revision: Union[str, None] = 'b7df9609542c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('room', sa.Column('background_information', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('room', 'background_information')

View File

@@ -0,0 +1,35 @@
"""add_unique_constraint_one_active_meeting_per_room
Revision ID: b7df9609542c
Revises: d7fbb74b673b
Create Date: 2025-07-25 16:27:06.959868
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b7df9609542c'
down_revision: Union[str, None] = 'd7fbb74b673b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create a partial unique index that ensures only one active meeting per room
# This works for both PostgreSQL and SQLite
op.create_index(
'idx_one_active_meeting_per_room',
'meeting',
['room_id'],
unique=True,
postgresql_where=sa.text('is_active = true'),
sqlite_where=sa.text('is_active = 1')
)
def downgrade() -> None:
op.drop_index('idx_one_active_meeting_per_room', table_name='meeting')

View File

@@ -22,7 +22,6 @@ dependencies = [
"fastapi-pagination>=0.12.6",
"databases[aiosqlite, asyncpg]>=0.7.0",
"sqlalchemy<1.5",
"fief-client[fastapi]>=0.17.0",
"alembic>=1.11.3",
"nltk>=3.8.1",
"prometheus-fastapi-instrumentator>=6.1.0",

View File

@@ -1,25 +0,0 @@
from fastapi.security import OAuth2AuthorizationCodeBearer
from fief_client import FiefAccessTokenInfo, FiefAsync, FiefUserInfo
from fief_client.integrations.fastapi import FiefAuth
from reflector.settings import settings
fief = FiefAsync(
settings.AUTH_FIEF_URL,
settings.AUTH_FIEF_CLIENT_ID,
settings.AUTH_FIEF_CLIENT_SECRET,
)
scheme = OAuth2AuthorizationCodeBearer(
f"{settings.AUTH_FIEF_URL}/authorize",
f"{settings.AUTH_FIEF_URL}/api/token",
scopes={"openid": "openid", "offline_access": "offline_access"},
auto_error=False,
)
auth = FiefAuth(fief, scheme)
UserInfo = FiefUserInfo
AccessTokenInfo = FiefAccessTokenInfo
authenticated = auth.authenticated()
current_user = auth.current_user()
current_user_optional = auth.current_user(optional=True)

View File

@@ -1,56 +0,0 @@
from reflector.db import database
from reflector.db.meetings import meetings
from reflector.db.rooms import rooms
from reflector.db.transcripts import transcripts
users_to_migrate = [
["123@lifex.pink", "63b727f5-485d-449f-b528-563d779b11ef", None],
["ana@monadical.com", "1bae2e4d-5c04-49c2-932f-a86266a6ca13", None],
["cspencer@sprocket.org", "614ed0be-392e-488c-bd19-6a9730fd0e9e", None],
["daniel.f.lopez.j@gmail.com", "ca9561bd-c989-4a1e-8877-7081cf62ae7f", None],
["jenalee@monadical.com", "c7c1e79e-b068-4b28-a9f4-29d98b1697ed", None],
["jennifer@rootandseed.com", "f5321727-7546-4b2b-b69d-095a931ef0c4", None],
["jose@monadical.com", "221f079c-7ce0-4677-90b7-0359b6315e27", None],
["labenclayton@gmail.com", "40078cd0-543c-40e4-9c2e-5ce57a686428", None],
["mathieu@monadical.com", "c7a36151-851e-4afa-9fab-aaca834bfd30", None],
["michal.flak.96@gmail.com", "3096eb5e-b590-41fc-a0d1-d152c1895402", None],
["sara@monadical.com", "31ab0cfe-5d2c-4c7a-84de-a29494714c99", None],
["sara@monadical.com", "b871e5f0-754e-447f-9c3d-19f629f0082b", None],
["sebastian@monadical.com", "f024f9d0-15d0-480f-8529-43959fc8b639", None],
["sergey@monadical.com", "5c4798eb-b9ab-4721-a540-bd96fc434156", None],
["sergey@monadical.com", "9dd8a6b4-247e-48fe-b1fb-4c84dd3c01bc", None],
["transient.tran@gmail.com", "617ba2d3-09b6-4b1f-a435-a7f41c3ce060", None],
]
async def migrate_user(email, user_id):
# if the email match the email in the users_to_migrate list
# reassign all transcripts/rooms/meetings to the new user_id
user_ids = [user[1] for user in users_to_migrate if user[0] == email]
if not user_ids:
return
# do not migrate back
if user_id in user_ids:
return
for old_user_id in user_ids:
query = (
transcripts.update()
.where(transcripts.c.user_id == old_user_id)
.values(user_id=user_id)
)
await database.execute(query)
query = (
rooms.update().where(rooms.c.user_id == old_user_id).values(user_id=user_id)
)
await database.execute(query)
query = (
meetings.update()
.where(meetings.c.user_id == old_user_id)
.values(user_id=user_id)
)
await database.execute(query)

View File

@@ -39,6 +39,7 @@ rooms = sqlalchemy.Table(
sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
),
sqlalchemy.Column("background_information", sqlalchemy.Text),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
)
@@ -58,6 +59,7 @@ class Room(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
background_information: str = ""
class RoomController:
@@ -106,6 +108,7 @@ class RoomController:
recording_type: str,
recording_trigger: str,
is_shared: bool,
background_information: str = "",
):
"""
Add a new room
@@ -121,6 +124,7 @@ class RoomController:
recording_type=recording_type,
recording_trigger=recording_trigger,
is_shared=is_shared,
background_information=background_information,
)
query = rooms.insert().values(**room.model_dump())
try:

View File

@@ -454,15 +454,47 @@ class PipelineMainFinalSummaries(PipelineMainFromTopics):
Generate summaries from the topics
"""
async def get_room(self):
"""Get room information for the transcript"""
if not self._transcript.room_id:
return None
return await rooms_controller.get_by_id(self._transcript.room_id)
def get_processors(self) -> list:
return [
TranscriptFinalSummaryProcessor.as_threaded(
transcript=self._transcript,
room=getattr(self, '_room', None),
callback=self.on_long_summary,
on_short_summary=self.on_short_summary,
),
]
async def create(self) -> Pipeline:
self.prepare()
# get transcript
self._transcript = transcript = await self.get_transcript()
# get room information
self._room = await self.get_room()
# create pipeline
processors = self.get_processors()
pipeline = Pipeline(*processors)
pipeline.options = self
pipeline.logger.bind(transcript_id=transcript.id)
pipeline.logger.info(f"{self.__class__.__name__} pipeline created")
# push topics
topics = self.get_transcript_topics(transcript)
for topic in topics:
await self.push(topic)
await self.flush()
return pipeline
class PipelineMainWaveform(PipelineMainFromTopics):
"""

View File

@@ -135,7 +135,7 @@ class Messages:
class SummaryBuilder:
def __init__(self, llm, filename: str | None = None, logger=None):
def __init__(self, llm, filename: str | None = None, logger=None, room=None):
self.transcript: str | None = None
self.recap: str | None = None
self.summaries: list[dict] = []
@@ -147,6 +147,7 @@ class SummaryBuilder:
self.llm_instance: LLM = llm
self.model_name: str = llm.model_name
self.logger = logger or structlog.get_logger()
self.room = room
self.m = Messages(model_name=self.model_name, logger=self.logger)
if filename:
self.read_transcript_from_file(filename)
@@ -465,26 +466,31 @@ class SummaryBuilder:
self.logger.debug("--- extract main subjects")
m = Messages(model_name=self.model_name, logger=self.logger)
m.add_system(
(
"You are an advanced transcription summarization assistant."
"Your task is to summarize discussions by focusing only on the main ideas contributed by participants."
# Prevent generating another transcription
"Exclude direct quotes and unnecessary details."
# Do not mention others participants just because they didn't contributed
"Only include participant names if they actively contribute to the subject."
# Prevent generation of summary with "no others participants contributed" etc
"Keep summaries concise and focused on main subjects without adding conclusions such as 'no other participant contributed'. "
# Avoid: In the discussion, they talked about...
"Do not include contextual preface. "
# Prevention to have too long summary
"Summary should fit in a single paragraph. "
# Using other pronouns that the participants or the group
'Mention the participants or the group using "they".'
# Avoid finishing the summary with "No conclusions were added by the summarizer"
"Do not mention conclusion if there is no conclusion"
)
system_prompt = (
"You are an advanced transcription summarization assistant."
"Your task is to summarize discussions by focusing only on the main ideas contributed by participants."
# Prevent generating another transcription
"Exclude direct quotes and unnecessary details."
# Do not mention others participants just because they didn't contributed
"Only include participant names if they actively contribute to the subject."
# Prevent generation of summary with "no others participants contributed" etc
"Keep summaries concise and focused on main subjects without adding conclusions such as 'no other participant contributed'. "
# Avoid: In the discussion, they talked about...
"Do not include contextual preface. "
# Prevention to have too long summary
"Summary should fit in a single paragraph. "
# Using other pronouns that the participants or the group
'Mention the participants or the group using "they".'
# Avoid finishing the summary with "No conclusions were added by the summarizer"
"Do not mention conclusion if there is no conclusion"
)
# Add room context if available
if self.room and self.room.background_information:
system_prompt += f"\n\nContext about this meeting room: {self.room.background_information}"
m.add_system(system_prompt)
m.add_user(
f"# Transcript\n\n{self.transcript}\n\n"
+ (

View File

@@ -12,9 +12,10 @@ class TranscriptFinalSummaryProcessor(Processor):
INPUT_TYPE = TitleSummary
OUTPUT_TYPE = FinalLongSummary
def __init__(self, transcript=None, **kwargs):
def __init__(self, transcript=None, room=None, **kwargs):
super().__init__(**kwargs)
self.transcript = transcript
self.room = room
self.chunks: list[TitleSummary] = []
self.llm = LLM.get_instance(model_name="NousResearch/Hermes-3-Llama-3.1-8B")
self.builder = None
@@ -23,7 +24,7 @@ class TranscriptFinalSummaryProcessor(Processor):
self.chunks.append(data)
async def get_summary_builder(self, text) -> SummaryBuilder:
builder = SummaryBuilder(self.llm)
builder = SummaryBuilder(self.llm, room=self.room)
builder.set_transcript(text)
await builder.identify_participants()
await builder.generate_summary()

View File

@@ -90,14 +90,9 @@ class Settings(BaseSettings):
# Sentry
SENTRY_DSN: str | None = None
# User authentication (none, fief)
# User authentication (none, jwt)
AUTH_BACKEND: str = "none"
# User authentication using fief
AUTH_FIEF_URL: str | None = None
AUTH_FIEF_CLIENT_ID: str | None = None
AUTH_FIEF_CLIENT_SECRET: str | None = None
# User authentication using JWT
AUTH_JWT_ALGORITHM: str = "RS256"
AUTH_JWT_PUBLIC_KEY: str | None = "authentik.monadical.com_public.pem"

View File

@@ -0,0 +1,314 @@
"""
@vibe-generated
Process audio file with diarization support
===========================================
Extended version of process.py that includes speaker diarization.
This tool processes audio files locally without requiring the full server infrastructure.
"""
import asyncio
import tempfile
from pathlib import Path
from typing import List
import uuid
import av
from reflector.logger import logger
from reflector.processors import (
AudioChunkerProcessor,
AudioMergeProcessor,
AudioTranscriptAutoProcessor,
AudioFileWriterProcessor,
Pipeline,
PipelineEvent,
TranscriptFinalSummaryProcessor,
TranscriptFinalTitleProcessor,
TranscriptLinerProcessor,
TranscriptTopicDetectorProcessor,
TranscriptTranslatorProcessor,
)
from reflector.processors.base import BroadcastProcessor, Processor
from reflector.processors.types import (
AudioDiarizationInput,
TitleSummary,
TitleSummaryWithId,
)
class TopicCollectorProcessor(Processor):
"""Collect topics for diarization"""
INPUT_TYPE = TitleSummary
OUTPUT_TYPE = TitleSummary
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.topics: List[TitleSummaryWithId] = []
self._topic_id = 0
async def _push(self, data: TitleSummary):
# Convert to TitleSummaryWithId and collect
self._topic_id += 1
topic_with_id = TitleSummaryWithId(
id=str(self._topic_id),
title=data.title,
summary=data.summary,
timestamp=data.timestamp,
duration=data.duration,
transcript=data.transcript,
)
self.topics.append(topic_with_id)
# Pass through the original topic
await self.emit(data)
def get_topics(self) -> List[TitleSummaryWithId]:
return self.topics
async def process_audio_file_with_diarization(
filename,
event_callback,
only_transcript=False,
source_language="en",
target_language="en",
enable_diarization=True,
diarization_backend="modal",
):
# Create temp file for audio if diarization is enabled
audio_temp_path = None
if enable_diarization:
audio_temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
audio_temp_path = audio_temp_file.name
audio_temp_file.close()
# Create processor for collecting topics
topic_collector = TopicCollectorProcessor()
# Build pipeline for audio processing
processors = []
# Add audio file writer at the beginning if diarization is enabled
if enable_diarization:
processors.append(AudioFileWriterProcessor(audio_temp_path))
# Add the rest of the processors
processors += [
AudioChunkerProcessor(),
AudioMergeProcessor(),
AudioTranscriptAutoProcessor.as_threaded(),
]
processors += [
TranscriptLinerProcessor(),
TranscriptTranslatorProcessor.as_threaded(),
]
if not only_transcript:
processors += [
TranscriptTopicDetectorProcessor.as_threaded(),
# Collect topics for diarization
topic_collector,
BroadcastProcessor(
processors=[
TranscriptFinalTitleProcessor.as_threaded(),
TranscriptFinalSummaryProcessor.as_threaded(),
],
),
]
# Create main pipeline
pipeline = Pipeline(*processors)
pipeline.set_pref("audio:source_language", source_language)
pipeline.set_pref("audio:target_language", target_language)
pipeline.describe()
pipeline.on(event_callback)
# Start processing audio
logger.info(f"Opening {filename}")
container = av.open(filename)
try:
logger.info("Start pushing audio into the pipeline")
for frame in container.decode(audio=0):
await pipeline.push(frame)
finally:
logger.info("Flushing the pipeline")
await pipeline.flush()
# Run diarization if enabled and we have topics
if enable_diarization and not only_transcript and audio_temp_path:
topics = topic_collector.get_topics()
if topics:
logger.info(f"Starting diarization with {len(topics)} topics")
try:
# Import diarization processor
from reflector.processors import AudioDiarizationAutoProcessor
# Create diarization processor
diarization_processor = AudioDiarizationAutoProcessor(
name=diarization_backend
)
diarization_processor.on(event_callback)
# For Modal backend, we need to upload the file to S3 first
if diarization_backend == "modal":
from reflector.storage import get_transcripts_storage
from reflector.utils.s3_temp_file import S3TemporaryFile
from datetime import datetime
storage = get_transcripts_storage()
# Generate a unique filename in evaluation folder
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
audio_filename = f"evaluation/diarization_temp/{timestamp}_{uuid.uuid4().hex}.wav"
# Use context manager for automatic cleanup
async with S3TemporaryFile(storage, audio_filename) as s3_file:
# Read and upload the audio file
with open(audio_temp_path, "rb") as f:
audio_data = f.read()
audio_url = await s3_file.upload(audio_data)
logger.info(f"Uploaded audio to S3: {audio_filename}")
# Create diarization input with S3 URL
diarization_input = AudioDiarizationInput(
audio_url=audio_url, topics=topics
)
# Run diarization
await diarization_processor.push(diarization_input)
await diarization_processor.flush()
logger.info("Diarization complete")
# File will be automatically cleaned up when exiting the context
else:
# For local backend, use local file path
audio_url = audio_temp_path
# Create diarization input
diarization_input = AudioDiarizationInput(
audio_url=audio_url, topics=topics
)
# Run diarization
await diarization_processor.push(diarization_input)
await diarization_processor.flush()
logger.info("Diarization complete")
except ImportError as e:
logger.error(f"Failed to import diarization dependencies: {e}")
logger.error(
"Install with: uv pip install pyannote.audio torch torchaudio"
)
logger.error(
"And set HF_TOKEN environment variable for pyannote models"
)
raise SystemExit(1)
except Exception as e:
logger.error(f"Diarization failed: {e}")
raise SystemExit(1)
else:
logger.warning("Skipping diarization: no topics available")
# Clean up temp file
if audio_temp_path:
try:
Path(audio_temp_path).unlink()
except Exception as e:
logger.warning(f"Failed to clean up temp file {audio_temp_path}: {e}")
logger.info("All done!")
if __name__ == "__main__":
import argparse
import os
parser = argparse.ArgumentParser(
description="Process audio files with optional speaker diarization"
)
parser.add_argument("source", help="Source file (mp3, wav, mp4...)")
parser.add_argument(
"--only-transcript",
"-t",
action="store_true",
help="Only generate transcript without topics/summaries",
)
parser.add_argument(
"--source-language", default="en", help="Source language code (default: en)"
)
parser.add_argument(
"--target-language", default="en", help="Target language code (default: en)"
)
parser.add_argument("--output", "-o", help="Output file (output.jsonl)")
parser.add_argument(
"--enable-diarization",
"-d",
action="store_true",
help="Enable speaker diarization",
)
parser.add_argument(
"--diarization-backend",
default="modal",
choices=["modal"],
help="Diarization backend to use (default: modal)",
)
args = parser.parse_args()
# Set REDIS_HOST to localhost if not provided
if "REDIS_HOST" not in os.environ:
os.environ["REDIS_HOST"] = "localhost"
logger.info("REDIS_HOST not set, defaulting to localhost")
output_fd = None
if args.output:
output_fd = open(args.output, "w")
async def event_callback(event: PipelineEvent):
processor = event.processor
data = event.data
# Ignore internal processors
if processor in (
"AudioChunkerProcessor",
"AudioMergeProcessor",
"AudioFileWriterProcessor",
"TopicCollectorProcessor",
"BroadcastProcessor",
):
return
# If diarization is enabled, skip the original topic events from the pipeline
# The diarization processor will emit the same topics but with speaker info
if processor == "TranscriptTopicDetectorProcessor" and args.enable_diarization:
return
# Log all events
logger.info(f"Event: {processor} - {type(data).__name__}")
# Write to output
if output_fd:
output_fd.write(event.model_dump_json())
output_fd.write("\n")
output_fd.flush()
asyncio.run(
process_audio_file_with_diarization(
args.source,
event_callback,
only_transcript=args.only_transcript,
source_language=args.source_language,
target_language=args.target_language,
enable_diarization=args.enable_diarization,
diarization_backend=args.diarization_backend,
)
)
if output_fd:
output_fd.close()
logger.info(f"Output written to {args.output}")

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
@vibe-generated
Test script for the diarization CLI tool
=========================================
This script helps test the diarization functionality with sample audio files.
"""
import asyncio
import json
import sys
from pathlib import Path
from reflector.logger import logger
async def test_diarization(audio_file: str):
"""Test the diarization functionality"""
# Import the processing function
from process_with_diarization import process_audio_file_with_diarization
# Collect events
events = []
async def event_callback(event):
events.append({
"processor": event.processor,
"data": event.data
})
logger.info(f"Event from {event.processor}")
# Process the audio file
logger.info(f"Processing audio file: {audio_file}")
try:
await process_audio_file_with_diarization(
audio_file,
event_callback,
only_transcript=False,
source_language="en",
target_language="en",
enable_diarization=True,
diarization_backend="modal",
)
# Analyze results
logger.info(f"Processing complete. Received {len(events)} events")
# Look for diarization results
diarized_topics = []
for event in events:
if "TitleSummary" in event["processor"]:
# Check if words have speaker information
if hasattr(event["data"], "transcript") and event["data"].transcript:
words = event["data"].transcript.words
if words and hasattr(words[0], "speaker"):
speakers = set(w.speaker for w in words if hasattr(w, "speaker"))
logger.info(f"Found {len(speakers)} speakers in topic: {event['data'].title}")
diarized_topics.append(event["data"])
if diarized_topics:
logger.info(f"Successfully diarized {len(diarized_topics)} topics")
# Print sample output
sample_topic = diarized_topics[0]
logger.info("Sample diarized output:")
for i, word in enumerate(sample_topic.transcript.words[:10]):
logger.info(f" Word {i}: '{word.text}' - Speaker {word.speaker}")
else:
logger.warning("No diarization results found in output")
return events
except Exception as e:
logger.error(f"Error during processing: {e}")
raise
def main():
if len(sys.argv) < 2:
print("Usage: python test_diarization.py <audio_file>")
sys.exit(1)
audio_file = sys.argv[1]
if not Path(audio_file).exists():
print(f"Error: Audio file '{audio_file}' not found")
sys.exit(1)
# Run the test
asyncio.run(test_diarization(audio_file))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,149 @@
"""
@vibe-generated
S3 Temporary File Context Manager
Provides automatic cleanup of S3 files with retry logic and proper error handling.
"""
from typing import Optional
from reflector.storage.base import Storage
from reflector.logger import logger
from reflector.utils.retry import retry
class S3TemporaryFile:
"""
Async context manager for temporary S3 files with automatic cleanup.
Ensures that uploaded files are deleted even if exceptions occur during processing.
Uses retry logic for all S3 operations to handle transient failures.
Example:
async with S3TemporaryFile(storage, "temp/audio.wav") as s3_file:
url = await s3_file.upload(audio_data)
# Use url for processing
# File is automatically cleaned up here
"""
def __init__(self, storage: Storage, filepath: str):
"""
Initialize the temporary file context.
Args:
storage: Storage instance for S3 operations
filepath: S3 key/path for the temporary file
"""
self.storage = storage
self.filepath = filepath
self.uploaded = False
self._url: Optional[str] = None
async def __aenter__(self):
"""Enter the context manager."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""
Exit the context manager and clean up the file.
Cleanup is attempted even if an exception occurred during processing.
Cleanup failures are logged but don't raise exceptions.
"""
if self.uploaded:
try:
await self._delete_with_retry()
logger.info(f"Successfully cleaned up S3 file: {self.filepath}")
except Exception as e:
# Log the error but don't raise - we don't want cleanup failures
# to mask the original exception
logger.warning(
f"Failed to cleanup S3 file {self.filepath} after retries: {e}"
)
return False # Don't suppress exceptions
async def upload(self, data: bytes) -> str:
"""
Upload data to S3 and return the public URL.
Args:
data: File data to upload
Returns:
Public URL for the uploaded file
Raises:
Exception: If upload or URL generation fails after retries
"""
await self._upload_with_retry(data)
self.uploaded = True
self._url = await self._get_url_with_retry()
return self._url
@property
def url(self) -> Optional[str]:
"""Get the URL of the uploaded file, if available."""
return self._url
async def _upload_with_retry(self, data: bytes):
"""Upload file to S3 with retry logic."""
async def upload():
await self.storage.put_file(self.filepath, data)
logger.debug(f"Successfully uploaded file to S3: {self.filepath}")
return True # Return something to indicate success
await retry(upload)(
retry_attempts=3,
retry_timeout=30.0,
retry_backoff_interval=0.5,
retry_backoff_max=5.0,
)
async def _get_url_with_retry(self) -> str:
"""Get public URL for the file with retry logic."""
async def get_url():
url = await self.storage.get_file_url(self.filepath)
logger.debug(f"Generated public URL for S3 file: {self.filepath}")
return url
return await retry(get_url)(
retry_attempts=3,
retry_timeout=30.0,
retry_backoff_interval=0.5,
retry_backoff_max=5.0,
)
async def _delete_with_retry(self):
"""Delete file from S3 with retry logic."""
async def delete():
await self.storage.delete_file(self.filepath)
logger.debug(f"Successfully deleted S3 file: {self.filepath}")
return True # Return something to indicate success
await retry(delete)(
retry_attempts=3,
retry_timeout=30.0,
retry_backoff_interval=0.5,
retry_backoff_max=5.0,
)
# Convenience function for simpler usage
async def temporary_s3_file(storage: Storage, filepath: str):
"""
Create a temporary S3 file context manager.
This is a convenience wrapper around S3TemporaryFile for simpler usage.
Args:
storage: Storage instance for S3 operations
filepath: S3 key/path for the temporary file
Example:
async with temporary_s3_file(storage, "temp/audio.wav") as s3_file:
url = await s3_file.upload(audio_data)
# Use url for processing
"""
return S3TemporaryFile(storage, filepath)

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta
from typing import Annotated, Optional, Literal
import logging
import reflector.auth as auth
from fastapi import APIRouter, Depends, HTTPException
@@ -11,6 +12,10 @@ from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.settings import settings
from reflector.whereby import create_meeting, upload_logo
import asyncpg.exceptions
import sqlite3
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -28,6 +33,7 @@ class Room(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
background_information: str
class Meeting(BaseModel):
@@ -50,6 +56,7 @@ class CreateRoom(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
background_information: str = ""
class UpdateRoom(BaseModel):
@@ -62,6 +69,7 @@ class UpdateRoom(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
background_information: str = ""
class DeletionStatus(BaseModel):
@@ -103,6 +111,7 @@ async def rooms_create(
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
is_shared=room.is_shared,
background_information=room.background_information,
)
@@ -149,19 +158,47 @@ async def rooms_create_meeting(
if meeting is None:
end_date = current_time + timedelta(hours=8)
meeting = await create_meeting("", end_date=end_date, room=room)
await upload_logo(meeting["roomName"], "./images/logo.png")
meeting = await meetings_controller.create(
id=meeting["meetingId"],
room_name=meeting["roomName"],
room_url=meeting["roomUrl"],
host_room_url=meeting["hostRoomUrl"],
start_date=datetime.fromisoformat(meeting["startDate"]),
end_date=datetime.fromisoformat(meeting["endDate"]),
user_id=user_id,
room=room,
)
whereby_meeting = await create_meeting("", end_date=end_date, room=room)
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
# Now try to save to database
try:
meeting = await meetings_controller.create(
id=whereby_meeting["meetingId"],
room_name=whereby_meeting["roomName"],
room_url=whereby_meeting["roomUrl"],
host_room_url=whereby_meeting["hostRoomUrl"],
start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
user_id=user_id,
room=room,
)
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
# Another request already created a meeting for this room
# Log this race condition occurrence
logger.info(
"Race condition detected for room %s - fetching existing meeting",
room.name,
)
logger.warning(
"Whereby meeting %s was created but not used (resource leak) for room %s",
whereby_meeting["meetingId"],
room.name,
)
# Fetch the meeting that was created by the other request
meeting = await meetings_controller.get_active(
room=room, current_time=current_time
)
if meeting is None:
# Edge case: meeting was created but expired/deleted between checks
logger.error(
"Meeting disappeared after race condition for room %s", room.name
)
raise HTTPException(
status_code=503, detail="Unable to join meeting - please try again"
)
if user_id != room.user_id:
meeting.host_room_url = ""

View File

@@ -8,7 +8,6 @@ from fastapi_pagination.ext.databases import paginate
from jose import jwt
from pydantic import BaseModel, Field, field_serializer
from reflector.db.meetings import meetings_controller
from reflector.db.migrate_user import migrate_user
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import (
SourceKind,
@@ -114,10 +113,6 @@ async def transcripts_list(
user_id = user["sub"] if user else None
# for fief to jwt migration, migrate user if needed
if user:
await migrate_user(email=user["email"], user_id=user["sub"])
return await paginate(
database,
await transcripts_controller.get_all(

View File

@@ -0,0 +1,129 @@
"""
@vibe-generated
Tests for S3 temporary file context manager.
"""
import pytest
from unittest.mock import Mock, AsyncMock
from reflector.utils.s3_temp_file import S3TemporaryFile
@pytest.mark.asyncio
async def test_successful_upload_and_cleanup():
"""Test that file is uploaded and cleaned up on success."""
# Mock storage
mock_storage = Mock()
mock_storage.put_file = AsyncMock()
mock_storage.get_file_url = AsyncMock(return_value="https://example.com/file.wav")
mock_storage.delete_file = AsyncMock()
# Use context manager
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
url = await s3_file.upload(b"test data")
assert url == "https://example.com/file.wav"
assert s3_file.url == "https://example.com/file.wav"
# Verify operations
mock_storage.put_file.assert_called_once_with("test/file.wav", b"test data")
mock_storage.get_file_url.assert_called_once_with("test/file.wav")
mock_storage.delete_file.assert_called_once_with("test/file.wav")
@pytest.mark.asyncio
async def test_cleanup_on_exception():
"""Test that cleanup happens even when an exception occurs."""
# Mock storage
mock_storage = Mock()
mock_storage.put_file = AsyncMock()
mock_storage.get_file_url = AsyncMock(return_value="https://example.com/file.wav")
mock_storage.delete_file = AsyncMock()
# Use context manager with exception
with pytest.raises(ValueError):
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
await s3_file.upload(b"test data")
raise ValueError("Simulated error during processing")
# Verify cleanup still happened
mock_storage.delete_file.assert_called_once_with("test/file.wav")
@pytest.mark.asyncio
async def test_no_cleanup_if_not_uploaded():
"""Test that cleanup is skipped if file was never uploaded."""
# Mock storage
mock_storage = Mock()
mock_storage.delete_file = AsyncMock()
# Use context manager without uploading
async with S3TemporaryFile(mock_storage, "test/file.wav"):
pass # Don't upload anything
# Verify no cleanup attempted
mock_storage.delete_file.assert_not_called()
@pytest.mark.asyncio
async def test_cleanup_failure_is_logged_not_raised():
"""Test that cleanup failures are logged but don't raise exceptions."""
# Mock storage
mock_storage = Mock()
mock_storage.put_file = AsyncMock()
mock_storage.get_file_url = AsyncMock(return_value="https://example.com/file.wav")
mock_storage.delete_file = AsyncMock(side_effect=Exception("Delete failed"))
# Use context manager - should not raise
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
await s3_file.upload(b"test data")
# Verify delete was attempted (3 times due to retry)
assert mock_storage.delete_file.call_count == 3
@pytest.mark.asyncio
async def test_upload_retry_on_failure():
"""Test that upload is retried on failure."""
# Mock storage with failures then success
mock_storage = Mock()
mock_storage.put_file = AsyncMock(
side_effect=[Exception("Network error"), None] # Fail once, then succeed
)
mock_storage.get_file_url = AsyncMock(return_value="https://example.com/file.wav")
mock_storage.delete_file = AsyncMock()
# Use context manager
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
url = await s3_file.upload(b"test data")
assert url == "https://example.com/file.wav"
# Verify upload was retried
assert mock_storage.put_file.call_count == 2
@pytest.mark.asyncio
async def test_delete_retry_on_failure():
"""Test that delete is retried on failure."""
# Mock storage
mock_storage = Mock()
mock_storage.put_file = AsyncMock()
mock_storage.get_file_url = AsyncMock(return_value="https://example.com/file.wav")
mock_storage.delete_file = AsyncMock(
side_effect=[Exception("Network error"), None] # Fail once, then succeed
)
# Use context manager
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
await s3_file.upload(b"test data")
# Verify delete was retried
assert mock_storage.delete_file.call_count == 2
@pytest.mark.asyncio
async def test_properties_before_upload():
"""Test that properties work correctly before upload."""
mock_storage = Mock()
async with S3TemporaryFile(mock_storage, "test/file.wav") as s3_file:
assert s3_file.url is None
assert s3_file.uploaded is False

View File

@@ -1,6 +1,6 @@
import pytest
from unittest.mock import patch
from contextlib import asynccontextmanager
import pytest
from httpx import AsyncClient
@@ -261,67 +261,3 @@ async def test_transcript_mark_reviewed():
response = await ac.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["reviewed"] is True
@asynccontextmanager
async def patch_migrate_user():
with patch(
"reflector.db.migrate_user.users_to_migrate",
[["test@mail.com", "randomuserid", None]],
):
yield
@pytest.mark.asyncio
async def test_transcripts_list_authenticated_migration():
# XXX this test is a bit fragile, as it depends on the storage which
# is shared between tests
from reflector.app import app
testx1 = "testmigration1"
testx2 = "testmigration2"
async with patch_migrate_user(), AsyncClient(
app=app, base_url="http://test/v1"
) as ac:
# first ensure client 2 does not have any transcripts related to this test
async with authenticated_client2_ctx():
response = await ac.get("/transcripts")
assert response.status_code == 200
# assert len(response.json()["items"]) == 0
names = [t["name"] for t in response.json()["items"]]
assert testx1 not in names
assert testx2 not in names
# create 2 transcripts with client 1
async with authenticated_client_ctx():
response = await ac.post("/transcripts", json={"name": testx1})
assert response.status_code == 200
assert response.json()["name"] == testx1
response = await ac.post("/transcripts", json={"name": testx2})
assert response.status_code == 200
assert response.json()["name"] == testx2
response = await ac.get("/transcripts")
assert response.status_code == 200
assert len(response.json()["items"]) >= 2
names = [t["name"] for t in response.json()["items"]]
assert testx1 in names
assert testx2 in names
# now going back to client 2, migration should happen
async with authenticated_client2_ctx():
response = await ac.get("/transcripts")
assert response.status_code == 200
names = [t["name"] for t in response.json()["items"]]
assert testx1 in names
assert testx2 in names
# and client 1 should have nothing now
async with authenticated_client_ctx():
response = await ac.get("/transcripts")
assert response.status_code == 200
names = [t["name"] for t in response.json()["items"]]
assert testx1 not in names
assert testx2 not in names

43
server/uv.lock generated
View File

@@ -817,25 +817,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/69/28359d152f9e2ec1ff4dff3da47011b6346e9a472f89b409bb13017a7d1f/faster_whisper-1.1.1-py3-none-any.whl", hash = "sha256:5808dc334fb64fb4336921450abccfe5e313a859b31ba61def0ac7f639383d90", size = 1118368, upload-time = "2025-01-01T14:47:16.131Z" },
]
[[package]]
name = "fief-client"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "jwcrypto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/af/f6cc3ded8bdb901097b92a3ed444c48576a1b62f01352cb2fa069b0dd166/fief_client-0.20.0.tar.gz", hash = "sha256:dbfb906d03c4a5402ceac5c843aa4708535fb6f5d5c1c4e263ec06fbbbc434d7", size = 32465, upload-time = "2024-10-13T11:54:08.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/06/d33506317b4c9b71025eb010d96c4f7a8f89fa620ca30532c2e8e4390593/fief_client-0.20.0-py3-none-any.whl", hash = "sha256:425f40cc7c45c651daec63da402e033c53d91dcaa3f9bf208873fd8692fc16dc", size = 20219, upload-time = "2024-10-13T11:54:07.342Z" },
]
[package.optional-dependencies]
fastapi = [
{ name = "fastapi" },
{ name = "makefun" },
]
[[package]]
name = "filelock"
version = "3.18.0"
@@ -1211,19 +1192,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
]
[[package]]
name = "jwcrypto"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" },
]
[[package]]
name = "kombu"
version = "5.5.4"
@@ -1299,15 +1267,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "makefun"
version = "1.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/cf/6780ab8bc3b84a1cce3e4400aed3d64b6db7d5e227a2f75b6ded5674701a/makefun-1.16.0.tar.gz", hash = "sha256:e14601831570bff1f6d7e68828bcd30d2f5856f24bad5de0ccb22921ceebc947", size = 73565, upload-time = "2025-05-09T15:00:42.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/c0/4bc973defd1270b89ccaae04cef0d5fa3ea85b59b108ad2c08aeea9afb76/makefun-1.16.0-py2.py3-none-any.whl", hash = "sha256:43baa4c3e7ae2b17de9ceac20b669e9a67ceeadff31581007cca20a07bbe42c4", size = 22923, upload-time = "2025-05-09T15:00:41.042Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@@ -2172,7 +2131,6 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-pagination" },
{ name = "faster-whisper" },
{ name = "fief-client", extra = ["fastapi"] },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "loguru" },
@@ -2234,7 +2192,6 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" },
{ name = "fastapi-pagination", specifier = ">=0.12.6" },
{ name = "faster-whisper", specifier = ">=0.10.0" },
{ name = "fief-client", extras = ["fastapi"], specifier = ">=0.17.0" },
{ name = "httpx", specifier = ">=0.24.1" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "loguru", specifier = ">=0.7.0" },

86
www/REFACTOR2.md Normal file
View File

@@ -0,0 +1,86 @@
# Chakra UI v3 Migration - Remaining Tasks
## Completed
- ✅ Migrated from Chakra UI v2 to v3 in package.json
- ✅ Updated theme.ts with whiteAlpha color palette and semantic tokens
- ✅ Added button recipe with fontWeight 600 and hover states
- ✅ Moved Poppins font from theme to HTML tag className
- ✅ Fixed deprecated props across all files:
-`isDisabled``disabled` (all occurrences fixed)
-`isChecked``checked` (all occurrences fixed)
-`isLoading``loading` (all occurrences fixed)
-`isOpen``open` (all occurrences fixed)
-`noOfLines``lineClamp` (all occurrences fixed)
-`align``alignItems` on Flex/Stack components (all occurrences fixed)
-`justify``justifyContent` on Flex/Stack components (all occurrences fixed)
## Migration Summary
### Files Modified
1. **app/(app)/rooms/page.tsx**
- Fixed: isDisabled, isChecked, align, justify on multiple components
- Updated temporary Select component props
2. **app/(app)/transcripts/fileUploadButton.tsx**
- Fixed: isDisabled → disabled
3. **app/(app)/transcripts/shareZulip.tsx**
- Fixed: isDisabled → disabled
4. **app/(app)/transcripts/shareAndPrivacy.tsx**
- Fixed: isLoading → loading, isOpen → open
- Updated temporary Select component props
5. **app/(app)/browse/page.tsx**
- Fixed: isOpen → open, align → alignItems, justify → justifyContent
6. **app/(app)/transcripts/transcriptTitle.tsx**
- Fixed: noOfLines → lineClamp
7. **app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx**
- Fixed: noOfLines → lineClamp
8. **app/lib/expandableText.tsx**
- Fixed: noOfLines → lineClamp
9. **app/[roomName]/page.tsx**
- Fixed: align → alignItems, justify → justifyContent
10. **app/lib/WherebyWebinarEmbed.tsx**
- Fixed: align → alignItems, justify → justifyContent
## Other Potential Issues
1. Check for Modal/Dialog component imports and usage (currently using temporary replacements)
2. Review Select component usage (using temporary replacements)
3. Test button hover states for whiteAlpha color palette
4. Verify all color palettes work correctly with the new semantic tokens
## Testing
After completing migrations:
1. Run `yarn dev` and check all pages
2. Test buttons with different color palettes
3. Verify disabled states work correctly
4. Check that text alignment and flex layouts are correct
5. Test modal/dialog functionality
## Next Steps
The Chakra UI v3 migration is now largely complete for deprecated props. The main remaining items are:
- Replace temporary Modal and Select components with proper Chakra v3 implementations
- Thorough testing of all UI components
- Performance optimization if needed

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react";
import FullscreenModal from "./fullsreenModal";
import AboutContent from "./aboutContent";
import { Button } from "@chakra-ui/react";
type AboutProps = {
buttonText: string;
@@ -12,12 +13,9 @@ export default function About({ buttonText }: AboutProps) {
return (
<>
<button
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
onClick={() => setModalOpen(true)}
>
<Button mt={2} onClick={() => setModalOpen(true)} variant="subtle">
{buttonText}
</button>
</Button>
{modalOpen && (
<FullscreenModal close={() => setModalOpen(false)}>
<AboutContent />

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react";
import FullscreenModal from "./fullsreenModal";
import PrivacyContent from "./privacyContent";
import { Button } from "@chakra-ui/react";
type PrivacyProps = {
buttonText: string;
@@ -12,12 +13,9 @@ export default function Privacy({ buttonText }: PrivacyProps) {
return (
<>
<button
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
onClick={() => setModalOpen(true)}
>
<Button mt={2} onClick={() => setModalOpen(true)} variant="subtle">
{buttonText}
</button>
</Button>
{modalOpen && (
<FullscreenModal close={() => setModalOpen(false)}>
<PrivacyContent />

View File

@@ -0,0 +1,48 @@
import React from "react";
import { Button } from "@chakra-ui/react";
// import { Dialog } from "@chakra-ui/react";
interface DeleteTranscriptDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
cancelRef: React.RefObject<any>;
}
export default function DeleteTranscriptDialog({
isOpen,
onClose,
onConfirm,
cancelRef,
}: DeleteTranscriptDialogProps) {
// Temporarily return null to fix import issues
return null;
/* return (
<Dialog.Root
open={isOpen}
onOpenChange={(e) => !e.open && onClose()}
initialFocusEl={() => cancelRef.current}
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Delete Transcript
</Dialog.Header>
<Dialog.Body>
Are you sure? You can't undo this action afterwards.
</Dialog.Body>
<Dialog.Footer>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorPalette="red" onClick={onConfirm} ml={3}>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
); */
}

View File

@@ -0,0 +1,120 @@
import React from "react";
import { Box, Stack, Link, Heading } from "@chakra-ui/react";
import NextLink from "next/link";
import { Room, SourceKind } from "../../../api";
interface FilterSidebarProps {
rooms: Room[];
selectedSourceKind: SourceKind | null;
selectedRoomId: string;
onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void;
}
export default function FilterSidebar({
rooms,
selectedSourceKind,
selectedRoomId,
onFilterChange,
}: FilterSidebarProps) {
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
return (
<Box w={{ base: "full", md: "200px" }} p={4} bg="gray.100" rounded="md">
<Stack gap={2}>
<Link
as={NextLink}
fontSize="sm"
href="#"
onClick={() => onFilterChange(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
>
All Transcripts
</Link>
<Box borderBottomWidth="1px" my={2} />
{myRooms.length > 0 && (
<>
<Heading size="sm">My Rooms</Heading>
{myRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => onFilterChange("room", room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
: "normal"
}
fontSize="sm"
>
{room.name}
</Link>
))}
</>
)}
{sharedRooms.length > 0 && (
<>
<Heading size="sm">Shared Rooms</Heading>
{sharedRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => onFilterChange("room", room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
: "normal"
}
fontSize="sm"
>
{room.name}
</Link>
))}
</>
)}
<Box borderBottomWidth="1px" my={2} />
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange("live", "")}
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
fontSize="sm"
>
Live Transcripts
</Link>
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange("file", "")}
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
fontSize="sm"
>
Uploaded Files
</Link>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import { Pagination, IconButton, ButtonGroup } from "@chakra-ui/react";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
type PaginationProps = {
page: number;
setPage: (page: number) => void;
total: number;
size: number;
};
export default function PaginationComponent(props: PaginationProps) {
const { page, setPage, total, size } = props;
const totalPages = Math.ceil(total / size);
if (totalPages <= 1) return null;
return (
<Pagination.Root
count={total}
pageSize={size}
page={page}
onPageChange={(details) => setPage(details.page)}
style={{ display: "flex", justifyContent: "center" }}
>
<ButtonGroup variant="ghost" size="xs">
<Pagination.PrevTrigger asChild>
<IconButton>
<LuChevronLeft />
</IconButton>
</Pagination.PrevTrigger>
<Pagination.Items
render={(page) => (
<IconButton variant={{ base: "ghost", _selected: "solid" }}>
{page.value}
</IconButton>
)}
/>
<Pagination.NextTrigger asChild>
<IconButton>
<LuChevronRight />
</IconButton>
</Pagination.NextTrigger>
</ButtonGroup>
</Pagination.Root>
);
}

View File

@@ -0,0 +1,34 @@
import React, { useState } from "react";
import { Flex, Input, Button } from "@chakra-ui/react";
interface SearchBarProps {
onSearch: (searchTerm: string) => void;
}
export default function SearchBar({ onSearch }: SearchBarProps) {
const [searchInputValue, setSearchInputValue] = useState("");
const handleSearch = () => {
onSearch(searchInputValue);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
handleSearch();
}
};
return (
<Flex alignItems="center">
<Input
placeholder="Search transcriptions..."
value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button ml={2} onClick={handleSearch}>
Search
</Button>
</Flex>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { IconButton, Icon, Menu } from "@chakra-ui/react";
import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu";
interface TranscriptActionsMenuProps {
transcriptId: string;
onDelete: (transcriptId: string) => (e: any) => void;
onReprocess: (transcriptId: string) => (e: any) => void;
}
export default function TranscriptActionsMenu({
transcriptId,
onDelete,
onReprocess,
}: TranscriptActionsMenuProps) {
return (
<Menu.Root closeOnSelect={true} lazyMount={true}>
<Menu.Trigger asChild>
<IconButton aria-label="Options" size="sm" variant="ghost">
<LuMenu />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="reprocess"
onClick={(e) => onReprocess(transcriptId)(e)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
<Menu.Item value="delete" onClick={(e) => onDelete(transcriptId)(e)}>
<LuTrash /> Delete
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { Box, Stack, Text, Flex, Link, Spinner } from "@chakra-ui/react";
import NextLink from "next/link";
import { GetTranscriptMinimal } from "../../../api";
import { formatTimeMs, formatLocalDate } from "../../../lib/time";
import TranscriptStatusIcon from "./TranscriptStatusIcon";
import TranscriptActionsMenu from "./TranscriptActionsMenu";
interface TranscriptCardsProps {
transcripts: GetTranscriptMinimal[];
onDelete: (transcriptId: string) => (e: any) => void;
onReprocess: (transcriptId: string) => (e: any) => void;
loading?: boolean;
}
export default function TranscriptCards({
transcripts,
onDelete,
onReprocess,
loading,
}: TranscriptCardsProps) {
return (
<Box display={{ base: "block", lg: "none" }} position="relative">
{loading && (
<Flex
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="rgba(255, 255, 255, 0.8)"
zIndex={10}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" />
</Flex>
)}
<Box
opacity={loading ? 0.9 : 1}
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Stack gap={2}>
{transcripts.map((item) => (
<Box
key={item.id}
borderWidth={1}
p={4}
borderRadius="md"
fontSize="sm"
>
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
<TranscriptStatusIcon status={item.status} />
</Box>
<Box flex="1">
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
fontWeight="600"
display="block"
>
{item.title || "Unnamed Transcript"}
</Link>
<Text>
Source:{" "}
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Text>
<Text>Date: {formatLocalDate(item.created_at)}</Text>
<Text>Duration: {formatTimeMs(item.duration)}</Text>
</Box>
<TranscriptActionsMenu
transcriptId={item.id}
onDelete={onDelete}
onReprocess={onReprocess}
/>
</Flex>
</Box>
))}
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Icon, Box } from "@chakra-ui/react";
import {
FaCheck,
FaTrash,
FaStar,
FaMicrophone,
FaGear,
} from "react-icons/fa6";
interface TranscriptStatusIconProps {
status: string;
}
export default function TranscriptStatusIcon({
status,
}: TranscriptStatusIconProps) {
switch (status) {
case "ended":
return (
<Box as="span" title="Processing done">
<Icon color="green" as={FaCheck} />
</Box>
);
case "error":
return (
<Box as="span" title="Processing error">
<Icon color="red.500" as={FaTrash} />
</Box>
);
case "idle":
return (
<Box as="span" title="New meeting, no recording">
<Icon color="yellow.500" as={FaStar} />
</Box>
);
case "processing":
return (
<Box as="span" title="Processing in progress">
<Icon color="gray.500" as={FaGear} />
</Box>
);
case "recording":
return (
<Box as="span" title="Recording in progress">
<Icon color="blue.500" as={FaMicrophone} />
</Box>
);
default:
return null;
}
}

View File

@@ -0,0 +1,99 @@
import React from "react";
import { Box, Table, Link, Flex, Spinner } from "@chakra-ui/react";
import NextLink from "next/link";
import { GetTranscriptMinimal } from "../../../api";
import { formatTimeMs, formatLocalDate } from "../../../lib/time";
import TranscriptStatusIcon from "./TranscriptStatusIcon";
import TranscriptActionsMenu from "./TranscriptActionsMenu";
interface TranscriptTableProps {
transcripts: GetTranscriptMinimal[];
onDelete: (transcriptId: string) => (e: any) => void;
onReprocess: (transcriptId: string) => (e: any) => void;
loading?: boolean;
}
export default function TranscriptTable({
transcripts,
onDelete,
onReprocess,
loading,
}: TranscriptTableProps) {
return (
<Box display={{ base: "none", lg: "block" }} position="relative">
{loading && (
<Flex
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" />
</Flex>
)}
<Box
opacity={loading ? 0.9 : 1}
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeader
width="16px"
fontWeight="600"
></Table.ColumnHeader>
<Table.ColumnHeader width="400px" fontWeight="600">
Transcription Title
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Source
</Table.ColumnHeader>
<Table.ColumnHeader width="200px" fontWeight="600">
Date
</Table.ColumnHeader>
<Table.ColumnHeader width="100px" fontWeight="600">
Duration
</Table.ColumnHeader>
<Table.ColumnHeader
width="50px"
fontWeight="600"
></Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{transcripts.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>
<TranscriptStatusIcon status={item.status} />
</Table.Cell>
<Table.Cell>
<Link as={NextLink} href={`/transcripts/${item.id}`}>
{item.title || "Unnamed Transcript"}
</Link>
</Table.Cell>
<Table.Cell>
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Table.Cell>
<Table.Cell>{formatLocalDate(item.created_at)}</Table.Cell>
<Table.Cell>{formatTimeMs(item.duration)}</Table.Cell>
<Table.Cell>
<TranscriptActionsMenu
transcriptId={item.id}
onDelete={onDelete}
onReprocess={onReprocess}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
</Box>
);
}

View File

@@ -1,55 +1,18 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Flex,
Spinner,
Heading,
Box,
Text,
Link,
Stack,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Divider,
Input,
Icon,
Tooltip,
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Spacer,
} from "@chakra-ui/react";
import {
FaCheck,
FaTrash,
FaStar,
FaMicrophone,
FaGear,
FaEllipsisVertical,
FaArrowRotateRight,
} from "react-icons/fa6";
import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react";
import useTranscriptList from "../transcripts/useTranscriptList";
import useSessionUser from "../../lib/useSessionUser";
import NextLink from "next/link";
import { Room, GetTranscriptMinimal } from "../../api";
import Pagination from "./pagination";
import { formatTimeMs, formatLocalDate } from "../../lib/time";
import { Room } from "../../api";
import Pagination from "./_components/Pagination";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { SourceKind } from "../../api";
import FilterSidebar from "./_components/FilterSidebar";
import SearchBar from "./_components/SearchBar";
import TranscriptTable from "./_components/TranscriptTable";
import TranscriptCards from "./_components/TranscriptCards";
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
export default function TranscriptBrowser() {
const [selectedSourceKind, setSelectedSourceKind] =
@@ -58,7 +21,6 @@ export default function TranscriptBrowser() {
const [rooms, setRooms] = useState<Room[]>([]);
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [searchInputValue, setSearchInputValue] = useState("");
const { loading, response, refetch } = useTranscriptList(
page,
selectedSourceKind,
@@ -74,17 +36,10 @@ export default function TranscriptBrowser() {
React.useState<string>();
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
useEffect(() => {
setDeletedItemIds([]);
}, [page, response]);
useEffect(() => {
refetch();
}, [selectedRoomId, page, searchTerm]);
useEffect(() => {
if (!api) return;
api
@@ -100,33 +55,35 @@ export default function TranscriptBrowser() {
setSelectedSourceKind(sourceKind);
setSelectedRoomId(roomId);
setPage(1);
refetch();
};
const handleSearch = () => {
const handleSearch = (searchTerm: string) => {
setPage(1);
setSearchTerm(searchInputValue);
setSearchTerm(searchTerm);
setSelectedSourceKind(null);
setSelectedRoomId("");
refetch();
};
const handleKeyDown = (event) => {
if (event.key === "Enter") {
handleSearch();
}
};
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Spinner size="xl" />
</Flex>
);
if (!loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Text>
No transcripts found, but you can&nbsp;
<Link href="/transcripts/new" className="underline">
@@ -185,334 +142,64 @@ export default function TranscriptBrowser() {
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
p={4}
pt={4}
>
<Flex flexDir="row" justify="space-between" align="center" mb={4}>
<Heading size="md">
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={4}
>
<Heading size="lg">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{loading || (deletionLoading && <Spinner size="sm" />)}
</Heading>
</Flex>
<Flex flexDir={{ base: "column", md: "row" }}>
<Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100">
<Stack spacing={3}>
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
>
All Transcripts
</Link>
<FilterSidebar
rooms={rooms}
selectedSourceKind={selectedSourceKind}
selectedRoomId={selectedRoomId}
onFilterChange={handleFilterTranscripts}
/>
<Divider />
{myRooms.length > 0 && (
<>
<Heading size="sm">My Rooms</Heading>
{myRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("room", room.id)}
color={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
{sharedRooms.length > 0 && (
<>
<Heading size="sm">Shared Rooms</Heading>
{sharedRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("room", room.id)}
color={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
<Divider />
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("live", "")}
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
>
Live Transcripts
</Link>
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("file", "")}
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
>
Uploaded Files
</Link>
</Stack>
</Box>
<Flex flexDir="column" flex="1" p={4} gap={4}>
<Flex mb={4} alignItems="center">
<Input
placeholder="Search transcriptions..."
value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button ml={2} onClick={handleSearch}>
Search
</Button>
</Flex>
<Flex
flexDir="column"
flex="1"
pt={{ base: 4, md: 0 }}
pb={4}
gap={4}
px={{ base: 0, md: 4 }}
>
<SearchBar onSearch={handleSearch} />
<Pagination
page={page}
setPage={setPage}
total={response?.total || 0}
size={response?.size || 0}
/>
<Box display={{ base: "none", md: "block" }}>
<Table colorScheme="gray">
<Thead>
<Tr>
<Th pl={12} width="400px">
Transcription Title
</Th>
<Th width="150px">Source</Th>
<Th width="200px">Date</Th>
<Th width="100px">Duration</Th>
<Th width="50px"></Th>
</Tr>
</Thead>
<Tbody>
{response?.items?.map((item: GetTranscriptMinimal) => (
<Tr key={item.id}>
<Td>
<Flex alignItems="start">
{item.status === "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
)}
{item.status === "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
)}
{item.status === "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
)}
{item.status === "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
)}
{item.status === "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
)}
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
ml={2}
>
{item.title || "Unnamed Transcript"}
</Link>
</Flex>
</Td>
<Td>
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Td>
<Td>{formatLocalDate(item.created_at)}</Td>
<Td>{formatTimeMs(item.duration)}</Td>
<Td>
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={handleDeleteTranscript(item.id)}>
Delete
</MenuItem>
<MenuItem onClick={handleProcessTranscript(item.id)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Box display={{ base: "block", md: "none" }}>
<Stack spacing={2}>
{response?.items?.map((item: GetTranscriptMinimal) => (
<Box key={item.id} borderWidth={1} p={4} borderRadius="md">
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
{item.status === "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
)}
{item.status === "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
)}
{item.status === "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
)}
{item.status === "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
)}
{item.status === "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
)}
</Box>
<Box flex="1">
<Text fontWeight="bold">
{item.title || "Unnamed Transcript"}
</Text>
<Text>
Source:{" "}
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Text>
<Text>Date: {formatLocalDate(item.created_at)}</Text>
<Text>Duration: {formatTimeMs(item.duration)}</Text>
</Box>
<Menu>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={handleDeleteTranscript(item.id)}>
Delete
</MenuItem>
<MenuItem onClick={handleProcessTranscript(item.id)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))}
</Stack>
</Box>
<TranscriptTable
transcripts={response?.items || []}
onDelete={handleDeleteTranscript}
onReprocess={handleProcessTranscript}
loading={loading}
/>
<TranscriptCards
transcripts={response?.items || []}
onDelete={handleDeleteTranscript}
onReprocess={handleProcessTranscript}
loading={loading}
/>
</Flex>
</Flex>
<AlertDialog
<DeleteTranscriptDialog
isOpen={!!transcriptToDeleteId}
leastDestructiveRef={cancelRef}
onClose={onCloseDeletion}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Transcript
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onCloseDeletion}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={handleDeleteTranscript(transcriptToDeleteId)}
ml={3}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
onConfirm={() => handleDeleteTranscript(transcriptToDeleteId)(null)}
cancelRef={cancelRef}
/>
</Flex>
);
}

View File

@@ -1,79 +0,0 @@
import { Button, Flex, IconButton } from "@chakra-ui/react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
type PaginationProps = {
page: number;
setPage: (page: number) => void;
total: number;
size: number;
};
export default function Pagination(props: PaginationProps) {
const { page, setPage, total, size } = props;
const totalPages = Math.ceil(total / size);
const pageNumbers = Array.from(
{ length: totalPages },
(_, i) => i + 1,
).filter((pageNumber) => {
if (totalPages <= 3) {
// If there are 3 or fewer total pages, show all pages.
return true;
} else if (page <= 2) {
// For the first two pages, show the first 3 pages.
return pageNumber <= 3;
} else if (page >= totalPages - 1) {
// For the last two pages, show the last 3 pages.
return pageNumber >= totalPages - 2;
} else {
// For all other cases, show 3 pages centered around the current page.
return pageNumber >= page - 1 && pageNumber <= page + 1;
}
});
const canGoPrevious = page > 1;
const canGoNext = page < totalPages;
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
}
};
return (
<Flex justify="center" align="center" gap="2" mx="2">
<IconButton
isRound={true}
variant="text"
color={!canGoPrevious ? "gray" : "dark"}
mb="1"
icon={<FaChevronLeft />}
onClick={() => handlePageChange(page - 1)}
disabled={!canGoPrevious}
aria-label="Previous page"
/>
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="text"
color={page === pageNumber ? "gray" : "dark"}
onClick={() => handlePageChange(pageNumber)}
disabled={page === pageNumber}
>
{pageNumber}
</Button>
))}
<IconButton
isRound={true}
variant="text"
color={!canGoNext ? "gray" : "dark"}
icon={<FaChevronRight />}
mb="1"
onClick={() => handlePageChange(page + 1)}
disabled={!canGoNext}
aria-label="Next page"
/>
</Flex>
);
}

View File

@@ -1,4 +1,4 @@
import { Container, Flex, Link } from "@chakra-ui/layout";
import { Container, Flex, Link } from "@chakra-ui/react";
import { getConfig } from "../lib/edgeConfig";
import NextLink from "next/link";
import Image from "next/image";
@@ -61,12 +61,7 @@ export default async function AppLayout({
{browse ? (
<>
&nbsp;·&nbsp;
<Link
href="/browse"
as={NextLink}
className="font-light px-2"
prefetch={false}
>
<Link href="/browse" as={NextLink} className="font-light px-2">
Browse
</Link>
</>
@@ -76,12 +71,7 @@ export default async function AppLayout({
{rooms ? (
<>
&nbsp;·&nbsp;
<Link
href="/rooms"
as={NextLink}
className="font-light px-2"
prefetch={false}
>
<Link href="/rooms" as={NextLink} className="font-light px-2">
Rooms
</Link>
</>

View File

@@ -0,0 +1,37 @@
import React from "react";
import { IconButton, Menu } from "@chakra-ui/react";
import { LuMenu, LuPen, LuTrash } from "react-icons/lu";
interface RoomActionsMenuProps {
roomId: string;
roomData: any;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
}
export function RoomActionsMenu({
roomId,
roomData,
onEdit,
onDelete,
}: RoomActionsMenuProps) {
return (
<Menu.Root closeOnSelect={true} lazyMount={true}>
<Menu.Trigger asChild>
<IconButton aria-label="actions" variant="ghost">
<LuMenu />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item value="edit" onClick={() => onEdit(roomId, roomData)}>
<LuPen /> Edit
</Menu.Item>
<Menu.Item value="delete" onClick={() => onDelete(roomId)}>
<LuTrash /> Delete
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
}

View File

@@ -0,0 +1,126 @@
import React from "react";
import {
Box,
Card,
Flex,
Heading,
IconButton,
Link,
Spacer,
Text,
VStack,
HStack,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { Room } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomCardsProps {
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
}
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";
case "prompt":
return "Cloud (Prompt)";
case "automatic-2nd-participant":
return "Cloud (Auto)";
default:
return `Cloud`;
}
}
return type;
};
export function RoomCards({
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
}: RoomCardsProps) {
return (
<Box display={{ base: "block", lg: "none" }}>
<VStack gap={3} align="stretch">
{rooms.map((room) => (
<Card.Root key={room.id} size="sm">
<Card.Body>
<Flex alignItems="center" mt={-2}>
<Heading size="sm">
<Link href={`/${room.name}`}>{room.name}</Link>
</Heading>
<Spacer />
{linkCopied === room.name ? (
<Text color="green.500" mr={2} fontSize="sm">
Copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
onClick={() => onCopyUrl(room.name)}
mr={2}
size="sm"
variant="ghost"
>
<LuLink />
</IconButton>
)}
<RoomActionsMenu
roomId={room.id}
roomData={room}
onEdit={onEdit}
onDelete={onDelete}
/>
</Flex>
<VStack align="start" fontSize="sm" gap={0}>
{room.zulip_auto_post && (
<HStack gap={2}>
<Text fontWeight="500">Zulip:</Text>
<Text>
{room.zulip_stream && room.zulip_topic
? `${room.zulip_stream} > ${room.zulip_topic}`
: room.zulip_stream || "Enabled"}
</Text>
</HStack>
)}
<HStack gap={2}>
<Text fontWeight="500">Size:</Text>
<Text>{getRoomModeDisplay(room.room_mode)}</Text>
</HStack>
<HStack gap={2}>
<Text fontWeight="500">Recording:</Text>
<Text>
{getRecordingDisplay(
room.recording_type,
room.recording_trigger,
)}
</Text>
</HStack>
</VStack>
</Card.Body>
</Card.Root>
))}
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
import { Room } from "../../../api";
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
interface RoomListProps {
title: string;
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
emptyMessage?: string;
mb?: number | string;
pt?: number | string;
loading?: boolean;
}
export function RoomList({
title,
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
emptyMessage = "No rooms found",
mb,
pt,
loading,
}: RoomListProps) {
return (
<VStack alignItems="start" gap={4} mb={mb} pt={pt}>
<Heading size="md">{title}</Heading>
{rooms.length > 0 ? (
<Box w="full">
<RoomTable
rooms={rooms}
linkCopied={linkCopied}
onCopyUrl={onCopyUrl}
onEdit={onEdit}
onDelete={onDelete}
loading={loading}
/>
<RoomCards
rooms={rooms}
linkCopied={linkCopied}
onCopyUrl={onCopyUrl}
onEdit={onEdit}
onDelete={onDelete}
/>
</Box>
) : (
<Text>{emptyMessage}</Text>
)}
</VStack>
);
}

View File

@@ -0,0 +1,164 @@
import React from "react";
import {
Box,
Table,
Link,
Flex,
IconButton,
Text,
Spinner,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { Room } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomTableProps {
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
loading?: boolean;
}
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";
};
export function RoomTable({
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
loading,
}: RoomTableProps) {
return (
<Box display={{ base: "none", lg: "block" }} position="relative">
{loading && (
<Flex
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" />
</Flex>
)}
<Box
opacity={loading ? 0.9 : 1}
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeader width="250px" fontWeight="600">
Room Name
</Table.ColumnHeader>
<Table.ColumnHeader width="250px" fontWeight="600">
Zulip
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Room Size
</Table.ColumnHeader>
<Table.ColumnHeader width="200px" fontWeight="600">
Recording
</Table.ColumnHeader>
<Table.ColumnHeader
width="100px"
fontWeight="600"
></Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{rooms.map((room) => (
<Table.Row key={room.id}>
<Table.Cell>
<Link href={`/${room.name}`}>{room.name}</Link>
</Table.Cell>
<Table.Cell>
{getZulipDisplay(
room.zulip_auto_post,
room.zulip_stream,
room.zulip_topic,
)}
</Table.Cell>
<Table.Cell>{getRoomModeDisplay(room.room_mode)}</Table.Cell>
<Table.Cell>
{getRecordingDisplay(
room.recording_type,
room.recording_trigger,
)}
</Table.Cell>
<Table.Cell>
<Flex alignItems="center" gap={2}>
{linkCopied === room.name ? (
<Text color="green.500" fontSize="sm">
Copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
onClick={() => onCopyUrl(room.name)}
size="sm"
variant="ghost"
>
<LuLink />
</IconButton>
)}
<RoomActionsMenu
roomId={room.id}
roomData={room}
onEdit={onEdit}
onDelete={onDelete}
/>
</Flex>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
</Box>
);
}

View File

@@ -2,61 +2,44 @@
import {
Button,
Card,
CardBody,
Checkbox,
CloseButton,
Dialog,
Field,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Select,
Spinner,
Textarea,
createListCollection,
useDisclosure,
VStack,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Checkbox,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { Container } from "@chakra-ui/react";
import { FaEllipsisVertical, FaTrash, FaPencil, FaLink } from "react-icons/fa6";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { Select, Options, OptionBase } from "chakra-react-select";
import { ApiError } from "../../api";
import { Room } from "../../api";
import { RoomList } from "./_components/RoomList";
interface SelectOption extends OptionBase {
interface SelectOption {
label: string;
value: string;
}
const RESERVED_PATHS = ["browse", "rooms", "transcripts"];
const roomModeOptions: Options<SelectOption> = [
const roomModeOptions: SelectOption[] = [
{ label: "2-4 people", value: "normal" },
{ label: "2-200 people", value: "group" },
];
const recordingTriggerOptions: Options<SelectOption> = [
const recordingTriggerOptions: SelectOption[] = [
{ label: "None", value: "none" },
{ label: "Prompt", value: "prompt" },
{ label: "Automatic", value: "automatic-2nd-participant" },
];
const recordingTypeOptions: Options<SelectOption> = [
const recordingTypeOptions: SelectOption[] = [
{ label: "None", value: "none" },
{ label: "Local", value: "local" },
{ label: "Cloud", value: "cloud" },
@@ -72,10 +55,24 @@ const roomInitialState = {
recordingType: "cloud",
recordingTrigger: "automatic-2nd-participant",
isShared: false,
backgroundInformation: "",
};
export default function RoomsList() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { open, onOpen, onClose } = useDisclosure();
// Create collections for Select components
const roomModeCollection = createListCollection({
items: roomModeOptions,
});
const recordingTriggerCollection = createListCollection({
items: recordingTriggerOptions,
});
const recordingTypeCollection = createListCollection({
items: recordingTypeOptions,
});
const [room, setRoom] = useState(roomInitialState);
const [isEditing, setIsEditing] = useState(false);
const [editRoomId, setEditRoomId] = useState("");
@@ -131,15 +128,23 @@ export default function RoomsList() {
fetchZulipTopics();
}, [room.zulipStream, streams, api]);
const streamOptions: Options<SelectOption> = streams.map((stream) => {
const streamOptions: SelectOption[] = streams.map((stream) => {
return { label: stream.name, value: stream.name };
});
const topicOptions: Options<SelectOption> = topics.map((topic) => ({
const topicOptions: SelectOption[] = topics.map((topic) => ({
label: topic.name,
value: topic.name,
}));
const streamCollection = createListCollection({
items: streamOptions,
});
const topicCollection = createListCollection({
items: topicOptions,
});
const handleCopyUrl = (roomName: string) => {
const roomUrl = `${window.location.origin}/${roomName}`;
navigator.clipboard.writeText(roomUrl);
@@ -167,6 +172,7 @@ export default function RoomsList() {
recording_type: room.recordingType,
recording_trigger: room.recordingTrigger,
is_shared: room.isShared,
background_information: room.backgroundInformation,
};
if (isEditing) {
@@ -186,11 +192,10 @@ export default function RoomsList() {
setNameError("");
refetch();
onClose();
} catch (err) {
} catch (err: any) {
if (
err instanceof ApiError &&
err.status === 400 &&
(err.body as any).detail == "Room name is not unique"
err.body?.detail === "Room name is not unique"
) {
setNameError(
"This room name is already taken. Please choose a different name.",
@@ -212,6 +217,7 @@ export default function RoomsList() {
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
backgroundInformation: roomData.background_information || "",
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -245,312 +251,364 @@ export default function RoomsList() {
});
};
const myRooms =
const myRooms: Room[] =
response?.items.filter((roomData) => !roomData.is_shared) || [];
const sharedRooms =
const sharedRooms: Room[] =
response?.items.filter((roomData) => roomData.is_shared) || [];
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Spinner size="xl" />
</Flex>
);
return (
<>
<Container maxW={"container.lg"}>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
mb={2}
<Flex
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
pt={2}
>
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={4}
>
<Heading size="lg">Rooms {loading && <Spinner size="sm" />}</Heading>
<Button
colorPalette="primary"
onClick={() => {
setIsEditing(false);
setRoom(roomInitialState);
setNameError("");
onOpen();
}}
>
<Heading>Rooms</Heading>
<Spacer />
<Button
colorScheme="blue"
onClick={() => {
setIsEditing(false);
setRoom(roomInitialState);
setNameError("");
onOpen();
}}
>
Add Room
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{isEditing ? "Edit Room" : "Add Room"}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<FormLabel>Room name</FormLabel>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<FormHelperText>
No spaces or special characters allowed
</FormHelperText>
{nameError && <Text color="red.500">{nameError}</Text>}
</FormControl>
Add Room
</Button>
</Flex>
<FormControl mt={4}>
<Checkbox
name="isLocked"
isChecked={room.isLocked}
onChange={handleRoomChange}
>
Locked room
</Checkbox>
</FormControl>
<FormControl mt={4}>
<FormLabel>Room size</FormLabel>
<Select
name="roomMode"
options={roomModeOptions}
value={{
label: roomModeOptions.find(
(rm) => rm.value === room.roomMode,
)?.label,
value: room.roomMode,
}}
onChange={(newValue) =>
setRoom({
...room,
roomMode: newValue!.value,
})
}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Recording type</FormLabel>
<Select
name="recordingType"
options={recordingTypeOptions}
value={{
label: recordingTypeOptions.find(
(rt) => rt.value === room.recordingType,
)?.label,
value: room.recordingType,
}}
onChange={(newValue) =>
setRoom({
...room,
recordingType: newValue!.value,
recordingTrigger:
newValue!.value !== "cloud"
? "none"
: room.recordingTrigger,
})
}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Cloud recording start trigger</FormLabel>
<Select
name="recordingTrigger"
options={recordingTriggerOptions}
value={{
label: recordingTriggerOptions.find(
(rt) => rt.value === room.recordingTrigger,
)?.label,
value: room.recordingTrigger,
}}
onChange={(newValue) =>
setRoom({
...room,
recordingTrigger: newValue!.value,
})
}
isDisabled={room.recordingType !== "cloud"}
/>
</FormControl>
<FormControl mt={8}>
<Checkbox
name="zulipAutoPost"
isChecked={room.zulipAutoPost}
onChange={handleRoomChange}
>
Automatically post transcription to Zulip
</Checkbox>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip stream</FormLabel>
<Select
name="zulipStream"
options={streamOptions}
placeholder="Select stream"
value={{ label: room.zulipStream, value: room.zulipStream }}
onChange={(newValue) =>
setRoom({
...room,
zulipStream: newValue!.value,
zulipTopic: "",
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip topic</FormLabel>
<Select
name="zulipTopic"
options={topicOptions}
placeholder="Select topic"
value={{ label: room.zulipTopic, value: room.zulipTopic }}
onChange={(newValue) =>
setRoom({
...room,
zulipTopic: newValue!.value,
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<Checkbox
name="isShared"
isChecked={room.isShared}
onChange={handleRoomChange}
>
Shared room
</Checkbox>
</FormControl>
</ModalBody>
<Dialog.Root
open={open}
onOpenChange={(e) => (e.open ? onOpen() : onClose())}
size="lg"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{isEditing ? "Edit Room" : "Add Room"}
</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && <Field.ErrorText>{nameError}</Field.ErrorText>}
</Field.Root>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
</Button>
<Field.Root mt={4}>
<Field.Label>Background Information</Field.Label>
<Textarea
name="backgroundInformation"
placeholder="Provide context about this room's purpose, meeting type, or any relevant background information that will help generate better summaries..."
value={room.backgroundInformation}
onChange={handleRoomChange}
rows={3}
/>
<Field.HelperText>
This information will be used to provide context for AI-generated summaries
</Field.HelperText>
</Field.Root>
<Button
colorScheme="blue"
onClick={handleSaveRoom}
isDisabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
{isEditing ? "Save" : "Add"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Flex>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoom({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoom({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud" ? "none" : room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoom({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={8}>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoom({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoom({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
colorPalette="primary"
onClick={handleSaveRoom}
disabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}
>
{isEditing ? "Save" : "Add"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
<VStack align="start" mb={10} pt={4} gap={4}>
<Heading size="md">My Rooms</Heading>
{myRooms.length > 0 ? (
myRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
{linkCopied === roomData.name ? (
<Text mr={2} color="green.500">
Link copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
icon={<FaLink />}
onClick={() => handleCopyUrl(roomData.name)}
mr={2}
/>
)}
<RoomList
title="My Rooms"
rooms={myRooms}
linkCopied={linkCopied}
onCopyUrl={handleCopyUrl}
onEdit={handleEditRoom}
onDelete={handleDeleteRoom}
emptyMessage="No rooms found"
/>
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Text>No rooms found</Text>
)}
</VStack>
<VStack align="start" pt={4} gap={4}>
<Heading size="md">Shared Rooms</Heading>
{sharedRooms.length > 0 ? (
sharedRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
{linkCopied === roomData.name ? (
<Text mr={2} color="green.500">
Link copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
icon={<FaLink />}
onClick={() => handleCopyUrl(roomData.name)}
mr={2}
/>
)}
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Text>No shared rooms found</Text>
)}
</VStack>
</Container>
</>
<RoomList
title="Shared Rooms"
rooms={sharedRooms}
linkCopied={linkCopied}
onCopyUrl={handleCopyUrl}
onEdit={handleEditRoom}
onDelete={handleDeleteRoom}
emptyMessage="No shared rooms found"
pt={4}
/>
</Flex>
);
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { Page_Room_ } from "../../api";
import { PageRoom } from "../../api";
type RoomList = {
response: Page_Room_ | null;
response: PageRoom | null;
loading: boolean;
error: Error | null;
refetch: () => void;
@@ -12,7 +12,7 @@ type RoomList = {
//always protected
const useRoomList = (page: number): RoomList => {
const [response, setResponse] = useState<Page_Room_ | null>(null);
const [response, setResponse] = useState<PageRoom | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();

View File

@@ -0,0 +1,61 @@
import { Box, Text, Accordion, Flex } from "@chakra-ui/react";
import { formatTime } from "../../../../lib/time";
import { Topic } from "../../webSocketTypes";
import { TopicSegment } from "./TopicSegment";
interface TopicItemProps {
topic: Topic;
isActive: boolean;
getSpeakerName: (speakerNumber: number) => string | undefined;
}
export function TopicItem({ topic, isActive, getSpeakerName }: TopicItemProps) {
return (
<Accordion.Item value={topic.id} id={`topic-${topic.id}`}>
<Accordion.ItemTrigger
background={isActive ? "gray.50" : "white"}
display="flex"
alignItems="start"
justifyContent="space-between"
>
<Flex
display="flex"
justifyContent="center"
alignItems="center"
height="24px"
width="24px"
>
<Accordion.ItemIndicator />
</Flex>
<Box flex="1">{topic.title} </Box>
<Text as="span" color="gray.500" fontSize="xs" pr={1}>
{formatTime(topic.timestamp)}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody p={4}>
{isActive && (
<>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<TopicSegment
key={index}
segment={segment}
speakerName={
getSpeakerName(segment.speaker) ||
`Speaker ${segment.speaker}`
}
/>
))}
</>
) : (
<>{topic.transcript}</>
)}
</>
)}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
);
}

View File

@@ -1,20 +1,10 @@
import React, { useState, useEffect } from "react";
import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Text,
} from "@chakra-ui/react";
import { featureEnabled } from "../../domainContext";
import ScrollToBottom from "../../scrollToBottom";
import { Topic } from "../../webSocketTypes";
import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
type TopicListProps = {
topics: Topic[];
@@ -41,9 +31,7 @@ export function TopicList({
const participants = useParticipants(transcriptId);
const scrollToTopic = () => {
const topicDiv = document.getElementById(
`accordion-button-topic-${activeTopic?.id}`,
);
const topicDiv = document.getElementById(`topic-${activeTopic?.id}`);
setTimeout(() => {
topicDiv?.scrollIntoView({
@@ -133,88 +121,29 @@ export function TopicList({
h={"100%"}
onScroll={handleScroll}
width="full"
padding={2}
>
{topics.length > 0 && (
<Accordion
index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
variant="custom"
allowToggle
<Accordion.Root
multiple={false}
collapsible={true}
value={activeTopic ? [activeTopic.id] : []}
onValueChange={(details) => {
const selectedTopicId = details.value[0];
const selectedTopic = selectedTopicId
? topics.find((t) => t.id === selectedTopicId)
: null;
setActiveTopic(selectedTopic || null);
}}
>
{topics.map((topic, index) => (
<AccordionItem
key={index}
background={{
base: "light",
hover: "gray.100",
focus: "gray.100",
}}
id={`topic-${topic.id}`}
>
<Flex dir="row" letterSpacing={".2"}>
<AccordionButton
onClick={() => {
setActiveTopic(
activeTopic?.id == topic.id ? null : topic,
);
}}
>
<AccordionIcon />
<Box as="span" textAlign="left" ml="1">
{topic.title}{" "}
<Text
as="span"
color="gray.500"
fontSize="sm"
fontWeight="bold"
>
&nbsp;[{formatTime(topic.timestamp)}]&nbsp;-&nbsp;[
{formatTime(topic.timestamp + (topic.duration || 0))}]
</Text>
</Box>
</AccordionButton>
</Flex>
<AccordionPanel>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<Text
key={index}
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight={"1.3"}
>
<Text
as="span"
color={"gray.500"}
fontFamily={"monospace"}
fontSize={"sm"}
>
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight={"bold"}
fontSize={"sm"}
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{getSpeakerName(segment.speaker)}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
))}
</>
) : (
<>{topic.transcript}</>
)}
</AccordionPanel>
</AccordionItem>
{topics.map((topic) => (
<TopicItem
key={topic.id}
topic={topic}
isActive={activeTopic?.id === topic.id}
getSpeakerName={getSpeakerName}
/>
))}
</Accordion>
</Accordion.Root>
)}
{status == "recording" && (
@@ -225,7 +154,7 @@ export function TopicList({
{(status == "recording" || status == "idle") &&
currentTranscriptText.length == 0 &&
topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>
Full discussion transcript will appear here after you start
recording.
@@ -236,7 +165,7 @@ export function TopicList({
</Box>
)}
{status == "processing" && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>We are processing the recording, please wait.</Text>
{!requireLogin && (
<span>
@@ -246,12 +175,12 @@ export function TopicList({
</Box>
)}
{status == "ended" && topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>Recording has ended without topics being found.</Text>
</Box>
)}
{status == "error" && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>There was an error processing your recording</Text>
</Box>
)}

View File

@@ -0,0 +1,39 @@
import { Text } from "@chakra-ui/react";
import { formatTime } from "../../../../lib/time";
import { generateHighContrastColor } from "../../../../lib/utils";
interface TopicSegmentProps {
segment: {
start: number;
speaker: number;
text: string;
};
speakerName: string;
}
export function TopicSegment({ segment, speakerName }: TopicSegmentProps) {
return (
<Text
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight="1.3"
>
<Text as="span" color="gray.500" fontFamily="monospace" fontSize="sm">
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight="bold"
fontSize="sm"
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{speakerName}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
);
}

View File

@@ -0,0 +1,3 @@
export { TopicList } from "./TopicList";
export { TopicItem } from "./TopicItem";
export { TopicSegment } from "./TopicSegment";

View File

@@ -11,11 +11,10 @@ import {
Button,
Flex,
Text,
UnorderedList,
List,
Input,
Kbd,
Spinner,
ListItem,
Grid,
} from "@chakra-ui/react";
@@ -351,7 +350,7 @@ const ParticipantList = ({
/>
<Button
onClick={doAction}
colorScheme="blue"
colorPalette="blue"
disabled={!action || anyLoading}
>
{!anyLoading ? (
@@ -371,14 +370,14 @@ const ParticipantList = ({
</Flex>
{participants.response && (
<UnorderedList
<List.Root
mx="0"
mb={{ base: 2, md: 4 }}
maxH="100%"
overflow="scroll"
>
{participants.response.map((participant: Participant) => (
<ListItem
<List.Item
onClick={selectParticipant(participant)}
cursor="pointer"
className={
@@ -410,7 +409,7 @@ const ParticipantList = ({
!loading && (
<Button
onClick={mergeSpeaker(selectedText, participant)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
@@ -435,7 +434,7 @@ const ParticipantList = ({
{selectedTextIsTimeSlice(selectedText) && !loading && (
<Button
onClick={assignTo(participant)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
@@ -460,16 +459,16 @@ const ParticipantList = ({
<Button
onClick={deleteParticipant(participant.id)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
Delete
</Button>
</Box>
</ListItem>
</List.Item>
))}
</UnorderedList>
</List.Root>
)}
</Grid>
</Box>

View File

@@ -11,7 +11,7 @@ import {
SkeletonCircle,
Flex,
} from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { ChevronLeft, ChevronRight } from "lucide-react";
type TopicHeader = {
stateCurrentTopic: [
@@ -91,73 +91,62 @@ export default function TopicHeader({
justifyContent="space-between"
{...chakraProps}
>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
{isLoaded ? (
<Circle
as="button"
onClick={onPrev}
disabled={!canGoPrevious}
onClick={canGoPrevious ? onPrev : undefined}
size="40px"
border="1px"
color={canGoPrevious ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
cursor={canGoPrevious ? "pointer" : "not-allowed"}
opacity={canGoPrevious ? 1 : 0.5}
>
{canGoPrevious ? (
<Kbd>
<ChevronLeftIcon />
<ChevronLeft size={16} />
</Kbd>
) : (
<ChevronLeftIcon />
<ChevronLeft size={16} />
)}
</Circle>
</SkeletonCircle>
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
flexGrow={1}
mx={6}
>
<Flex wrap="nowrap" justifyContent="center">
<Heading size="lg" textAlign="center" noOfLines={1}>
) : (
<SkeletonCircle h="40px" w="40px" mb="2" />
)}
{isLoaded ? (
<Flex wrap="nowrap" justifyContent="center" flexGrow={1} mx={6}>
<Heading size="lg" textAlign="center" lineClamp={1}>
{currentTopic?.title}{" "}
</Heading>
<Heading size="lg" ml="3">
{(number || 0) + 1}/{total}
</Heading>
</Flex>
</Skeleton>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
) : (
<Skeleton h="40px" mb="2" flexGrow={1} mx={6} />
)}
{isLoaded ? (
<Circle
as="button"
onClick={onNext}
disabled={!canGoNext}
onClick={canGoNext ? onNext : undefined}
size="40px"
border="1px"
color={canGoNext ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
cursor={canGoNext ? "pointer" : "not-allowed"}
opacity={canGoNext ? 1 : 0.5}
>
{canGoNext ? (
<Kbd>
<ChevronRightIcon />
<ChevronRight size={16} />
</Kbd>
) : (
<ChevronRightIcon />
<ChevronRight size={16} />
)}
</Circle>
</SkeletonCircle>
) : (
<SkeletonCircle h="40px" w="40px" mb="2" />
)}
</Box>
);
}

View File

@@ -199,61 +199,54 @@ const TopicPlayer = ({
</Text>
);
}
return (
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
fadeDuration={1}
w={isLoaded ? "auto" : "container.md"}
margin="auto"
{...chakraProps}
>
<Wrap spacing="4" justify="center" align="center">
<WrapItem>
<SoundWaveCss playing={isPlaying} />
<Text fontSize="sm" pt="1" pl="2">
{showTime}
</Text>
</WrapItem>
<WrapItem>
<Button onClick={playTopic} colorScheme="blue">
Play from start
</Button>
</WrapItem>
<WrapItem>
{!isPlaying ? (
<Button
onClick={playCurrent}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Play
</Button>
) : (
<Button
onClick={pause}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Pause
</Button>
)}
</WrapItem>
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
return isLoaded ? (
<Wrap gap="4" justify="center" align="center">
<WrapItem>
<SoundWaveCss playing={isPlaying} />
<Text fontSize="sm" pt="1" pl="2">
{showTime}
</Text>
</WrapItem>
<WrapItem>
<Button onClick={playTopic} colorPalette="blue">
Play from start
</Button>
</WrapItem>
<WrapItem>
{!isPlaying ? (
<Button
disabled={!selectedTime}
onClick={playSelection}
colorScheme="blue"
onClick={playCurrent}
ref={playButton}
id="playButton"
colorPalette="blue"
w="120px"
>
<Kbd color="blue.600">,</Kbd>&nbsp;Play selection
<Kbd color="blue.600">Space</Kbd>&nbsp;Play
</Button>
</WrapItem>
</Wrap>
</Skeleton>
) : (
<Button
onClick={pause}
ref={playButton}
id="playButton"
colorPalette="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Pause
</Button>
)}
</WrapItem>
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
<Button
disabled={!selectedTime}
onClick={playSelection}
colorPalette="blue"
>
<Kbd color="blue.600">,</Kbd>&nbsp;Play selection
</Button>
</WrapItem>
</Wrap>
) : (
<Skeleton h="40px" w="container.md" margin="auto" {...chakraProps} />
);
};

View File

@@ -16,7 +16,7 @@ import {
Textarea,
Spacer,
} from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { LuPen } from "react-icons/lu";
import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
@@ -108,29 +108,26 @@ export default function FinalSummary(props: FinalSummaryProps) {
right="0"
>
{isEditMode && (
<>
<Flex gap={2} align="center" w="full">
<Heading size={{ base: "md" }}>Summary</Heading>
<Spacer />
<Button
onClick={onDiscardClick}
colorScheme="gray"
variant={"text"}
>
Discard
<Button onClick={onDiscardClick} variant="ghost">
Cancel
</Button>
<Button onClick={onSaveClick} colorScheme="blue">
Save
</Button>
</>
<Button onClick={onSaveClick}>Save</Button>
</Flex>
)}
{!isEditMode && (
<>
<Spacer />
<IconButton
icon={<FaPen />}
aria-label="Edit Summary"
onClick={onEditClick}
/>
size="sm"
variant="subtle"
>
<LuPen />
</IconButton>
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}

View File

@@ -4,10 +4,9 @@ import useTranscript from "../useTranscript";
import useTopics from "../useTopics";
import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
import { TopicList } from "../topicList";
import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import "../../../styles/button.css";
import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
@@ -104,7 +103,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
base: "auto minmax(0, 1fr) minmax(0, 1fr)",
md: "auto minmax(0, 1fr)",
}}
gap={2}
gap={4}
gridRowGap={2}
padding={4}
paddingBottom={0}
background="gray.bg"

View File

@@ -1,10 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../../topicList";
import { TopicList } from "../_components/TopicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
@@ -105,7 +104,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
</Box>
<Box w={{ md: "50%" }} h={{ base: "20%", md: "full" }}>
{!transcriptStarted ? (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>
Live transcript will appear here shortly after you'll start
recording.

View File

@@ -1,179 +0,0 @@
import React, { useContext, useState, useEffect } from "react";
import SelectSearch from "react-select-search";
import { GetTranscript, GetTranscriptTopic } from "../../../api";
import "react-select-search/style.css";
import { DomainContext } from "../../../domainContext";
import useApi from "../../../lib/useApi";
type ShareModalProps = {
show: boolean;
setShow: (show: boolean) => void;
transcript: GetTranscript | null;
topics: GetTranscriptTopic[] | null;
};
interface Stream {
stream_id: number;
name: string;
}
interface Topic {
name: string;
}
interface SelectSearchOption {
name: string;
value: string;
}
const ShareModal = (props: ShareModalProps) => {
const [stream, setStream] = useState<string | undefined>(undefined);
const [topic, setTopic] = useState<string | undefined>(undefined);
const [includeTopics, setIncludeTopics] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const api = useApi();
useEffect(() => {
const fetchZulipStreams = async () => {
if (!api) return;
try {
const response = await api.v1ZulipGetStreams();
setStreams(response);
setIsLoading(false);
} catch (error) {
console.error("Error fetching Zulip streams:", error);
}
};
fetchZulipStreams();
}, [!api]);
useEffect(() => {
const fetchZulipTopics = async () => {
if (!api || !stream) return;
try {
const selectedStream = streams.find((s) => s.name === stream);
if (selectedStream) {
const response = await api.v1ZulipGetTopics({
streamId: selectedStream.stream_id,
});
setTopics(response);
}
} catch (error) {
console.error("Error fetching Zulip topics:", error);
}
};
fetchZulipTopics();
}, [stream, streams, api]);
const handleSendToZulip = async () => {
if (!api || !props.transcript) return;
if (stream && topic) {
try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcript.id,
stream,
topic,
includeTopics,
});
} catch (error) {
console.log(error);
}
}
};
if (props.show && isLoading) {
return <div>Loading...</div>;
}
const streamOptions: SelectSearchOption[] = streams.map((stream) => ({
name: stream.name,
value: stream.name,
}));
const topicOptions: SelectSearchOption[] = topics.map((topic) => ({
name: topic.name,
value: topic.name,
}));
return (
<div className="absolute">
{props.show && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<h3 className="font-bold text-xl">Send to Zulip</h3>
{/* Checkbox for 'Include Topics' */}
<div className="mt-4 text-left ml-5">
<label className="flex items-center">
<input
type="checkbox"
className="form-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
checked={includeTopics}
onChange={(e) => setIncludeTopics(e.target.checked)}
/>
<span className="ml-2">Include topics</span>
</label>
</div>
<div className="flex items-center mt-4">
<span className="mr-2">#</span>
<SelectSearch
search={true}
options={streamOptions}
value={stream}
onChange={(val) => {
setTopic(undefined); // Reset topic when stream changes
setStream(val.toString());
}}
placeholder="Pick a stream"
/>
</div>
{stream && (
<div className="flex items-center mt-4">
<span className="mr-2 invisible">#</span>
<SelectSearch
search={true}
options={topicOptions}
value={topic}
onChange={(val) => setTopic(val.toString())}
placeholder="Pick a topic"
/>
</div>
)}
<button
className={`bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded py-2 px-4 mr-3 ${
!stream || !topic ? "opacity-50 cursor-not-allowed" : ""
}`}
disabled={!stream || !topic}
onClick={() => {
handleSendToZulip();
props.setShow(false);
}}
>
Send to Zulip
</button>
<button
className="bg-red-500 hover:bg-red-700 focus-visible:bg-red-700 text-white rounded py-2 px-4 mt-4"
onClick={() => props.setShow(false)}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ShareModal;

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
@@ -62,18 +61,14 @@ const TranscriptUpload = (details: TranscriptUpload) => {
<>
<VStack
align={"left"}
w="full"
h="full"
mb={4}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
p="4"
pt={4}
mx="auto"
w={{ base: "full", md: "container.xl" }}
>
<Heading size={"lg"}>Upload meeting</Heading>
<Center h={"full"} w="full">
<VStack spacing={10}>
<VStack gap={10} bg="gray.100" p={10} borderRadius="md" maxW="500px">
{status && status == "idle" && (
<>
<Text>
@@ -94,7 +89,6 @@ const TranscriptUpload = (details: TranscriptUpload) => {
processed.
</Text>
<Button
colorScheme="blue"
onClick={() => {
router.push("/browse");
}}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import useApi from "../../lib/useApi";
import { Button, CircularProgress } from "@chakra-ui/react";
import { Button, Spinner } from "@chakra-ui/react";
type FileUploadButton = {
transcriptId: string;
@@ -63,16 +63,11 @@ export default function FileUploadButton(props: FileUploadButton) {
return (
<>
<Button
onClick={triggerFileUpload}
colorScheme="blue"
mr={2}
isDisabled={progress > 0}
>
<Button onClick={triggerFileUpload} mr={2} disabled={progress > 0}>
{progress > 0 && progress < 100 ? (
<>
Uploading...&nbsp;
<CircularProgress size="20px" value={progress} />
<Spinner size="sm" />
</>
) : (
<>Select File</>

View File

@@ -2,7 +2,6 @@
import React, { useEffect, useState } from "react";
import useAudioDevice from "../useAudioDevice";
import "react-select-search/style.css";
import "../../../styles/button.css";
import "../../../styles/form.scss";
import About from "../../../(aboutAndPrivacy)/about";
import Privacy from "../../../(aboutAndPrivacy)/privacy";
@@ -30,15 +29,6 @@ import {
IconButton,
Spacer,
Menu,
MenuButton,
MenuItem,
MenuList,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Tooltip,
Input,
} from "@chakra-ui/react";
@@ -107,18 +97,18 @@ const TranscriptCreate = () => {
>
<Flex
flexDir={{ base: "column", md: "row" }}
justify="space-between"
align="center"
justifyContent="space-between"
alignItems="center"
gap={8}
>
<Flex
flexDir="column"
h="full"
justify="evenly"
justifyContent="evenly"
flexBasis="1"
flexGrow={1}
>
<Heading size="lg" textAlign={{ base: "center", md: "left" }}>
<Heading size="2xl" textAlign={{ base: "center", md: "left" }}>
Welcome to Reflector
</Heading>
<Text mt={6}>
@@ -143,9 +133,7 @@ const TranscriptCreate = () => {
{isLoading ? (
<Spinner />
) : requireLogin && !isAuthenticated ? (
<Button onClick={() => signIn("authentik")} colorScheme="blue">
Log in
</Button>
<Button onClick={() => signIn("authentik")}>Log in</Button>
) : (
<Flex
rounded="xl"
@@ -156,7 +144,7 @@ const TranscriptCreate = () => {
flexDir="column"
my={4}
>
<Heading size="md" mb={4}>
<Heading size="xl" mb={4}>
Try Reflector
</Heading>
<Box mb={4}>
@@ -191,7 +179,7 @@ const TranscriptCreate = () => {
</Text>
) : (
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={requestPermission}
disabled={permissionDenied}
>
@@ -202,20 +190,20 @@ const TranscriptCreate = () => {
<Text className="">Checking permissions...</Text>
)}
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={send}
isDisabled={!permissionOk || loadingRecord || loadingUpload}
disabled={!permissionOk || loadingRecord || loadingUpload}
mt={2}
>
{loadingRecord ? "Loading..." : "Record Meeting"}
</Button>
<Text align="center" m="2">
<Text textAlign="center" m="2">
OR
</Text>
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={uploadFile}
isDisabled={loadingRecord || loadingUpload}
disabled={loadingRecord || loadingUpload}
>
{loadingUpload ? "Loading..." : "Upload File"}
</Button>

View File

@@ -8,8 +8,7 @@ import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api";
import { waveSurferStyles } from "../../styles/recorder";
import { Box, Flex, IconButton } from "@chakra-ui/react";
import PlayIcon from "../../styles/icons/play";
import PauseIcon from "../../styles/icons/pause";
import { LuPause, LuPlay } from "react-icons/lu";
type PlayerProps = {
topics: Topic[];
@@ -167,13 +166,15 @@ export default function Player(props: PlayerProps) {
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isPlaying ? "Pause" : "Play"}
icon={isPlaying ? <PauseIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
mr={2}
id="play-btn"
onClick={handlePlayClick}
/>
size="sm"
>
{isPlaying ? <LuPause /> : <LuPlay />}
</IconButton>
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>

View File

@@ -9,20 +9,8 @@ import { useError } from "../../(errors)/errorContext";
import FileUploadButton from "./fileUploadButton";
import useWebRTC from "./useWebRTC";
import useAudioDevice from "./useAudioDevice";
import {
Box,
Flex,
IconButton,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
} from "@chakra-ui/react";
import StopRecordIcon from "../../styles/icons/stopRecord";
import PlayIcon from "../../styles/icons/play";
import { LuScreenShare } from "react-icons/lu";
import { FaMicrophone } from "react-icons/fa";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuStopCircle } from "react-icons/lu";
type RecorderProps = {
transcriptId: string;
@@ -139,7 +127,7 @@ export default function Recorder(props: RecorderProps) {
} else {
clearInterval(timeInterval as number);
setCurrentTime((prev) => {
setDuration(prev);
setDuration(prev / 1000);
return 0;
});
}
@@ -260,48 +248,56 @@ export default function Recorder(props: RecorderProps) {
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isRecording ? "Stop" : "Record"}
icon={isRecording ? <StopRecordIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
mr={2}
onClick={handleRecClick}
/>
>
{isRecording ? <LuStopCircle /> : <LuPlay />}
</IconButton>
{!isRecording && (window as any).chrome && (
<IconButton
aria-label={"Record Tab"}
icon={<LuScreenShare />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
disabled={isRecording}
mr={2}
onClick={handleRecordTabClick}
/>
size="sm"
>
<LuScreenShare />
</IconButton>
)}
{audioDevices && audioDevices?.length > 0 && deviceId && !isRecording && (
<Menu>
<MenuButton
as={IconButton}
aria-label={"Switch microphone"}
icon={<FaMicrophone />}
variant={"ghost"}
disabled={isRecording}
colorScheme={"blue"}
mr={2}
/>
<MenuList>
<MenuOptionGroup defaultValue={audioDevices[0].value} type="radio">
{audioDevices.map((device) => (
<MenuItemOption
key={device.value}
value={device.value}
onClick={() => setDeviceId(device.value)}
>
{device.label}
</MenuItemOption>
))}
</MenuOptionGroup>
</MenuList>
</Menu>
<Menu.Root>
<Menu.Trigger asChild>
<IconButton
aria-label={"Switch microphone"}
variant={"ghost"}
disabled={isRecording}
colorPalette={"blue"}
mr={2}
size="sm"
>
<LuMic />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.RadioItemGroup
value={deviceId}
onValueChange={(e) => setDeviceId(e.value)}
>
{audioDevices.map((device) => (
<Menu.RadioItem key={device.value} value={device.value}>
<Menu.ItemIndicator />
{device.label}
</Menu.RadioItem>
))}
</Menu.RadioItemGroup>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
)}
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>

View File

@@ -7,18 +7,17 @@ import {
Box,
Flex,
IconButton,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
Dialog,
Portal,
CloseButton,
Select,
createListCollection,
} from "@chakra-ui/react";
import { FaShare } from "react-icons/fa";
import { LuShare2 } from "react-icons/lu";
import useApi from "../../lib/useApi";
import useSessionUser from "../../lib/useSessionUser";
import { CustomSession } from "../../lib/types";
import { Select } from "chakra-react-select";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
@@ -31,31 +30,41 @@ type ShareAndPrivacyProps = {
type ShareOption = { value: ShareMode; label: string };
const shareOptions = [
const shareOptionsData = [
{ label: "Private", value: toShareMode("private") },
{ label: "Secure", value: toShareMode("semi-private") },
{ label: "Public", value: toShareMode("public") },
];
const shareOptions = createListCollection({
items: shareOptionsData,
});
export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [showModal, setShowModal] = useState(false);
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>(
shareOptions.find(
shareOptionsData.find(
(option) => option.value === props.transcriptResponse.share_mode,
) || shareOptions[0],
) || shareOptionsData[0],
);
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const api = useApi();
const updateShareMode = async (selectedShareMode: any) => {
const updateShareMode = async (selectedValue: string) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
const selectedOption = shareOptionsData.find(
(option) => option.value === selectedValue,
);
if (!selectedOption) return;
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode.value),
share_mode: selectedValue as "public" | "semi-private" | "private",
};
const updatedTranscript = await api.v1TranscriptUpdate({
@@ -63,9 +72,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
requestBody,
});
setShareMode(
shareOptions.find(
shareOptionsData.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptions[0],
) || shareOptionsData[0],
);
setShareLoading(false);
};
@@ -79,72 +88,102 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
return (
<>
<IconButton
icon={<FaShare />}
onClick={() => setShowModal(true)}
aria-label="Share"
/>
<Modal
isOpen={!!showModal}
onClose={() => setShowModal(false)}
size={"xl"}
size="sm"
variant="subtle"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Share</ModalHeader>
<ModalBody>
{requireLogin && (
<Box mb={4}>
<Text size="sm" mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text size="sm" mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
<LuShare2 />
</IconButton>
<Dialog.Root
open={showModal}
onOpenChange={(e) => setShowModal(e.open)}
size="lg"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Share</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{requireLogin && (
<Box mb={4}>
<Text mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
{isOwner && api && (
<Select
options={
[
{ value: "private", label: "Private" },
{ label: "Secure", value: "semi-private" },
{ label: "Public", value: "public" },
] as any
}
value={shareMode}
onChange={updateShareMode}
isLoading={shareLoading}
{isOwner && api && (
<Select.Root
key={shareMode.value}
value={[shareMode.value]}
onValueChange={(e) => updateShareMode(e.value[0])}
disabled={shareLoading}
collection={shareOptions}
lazyMount={true}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText>{shareMode.label}</Select.ValueText>
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{shareOptions.items.map((option) => (
<Select.Item
key={option.value}
item={option}
label={option.label}
>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
)}
</Box>
)}
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
</Box>
)}
<Text size="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</Flex>
</Flex>
<ShareLink transcriptId={props.transcriptResponse.id} />
</ModalBody>
</ModalContent>
</Modal>
<ShareLink transcriptId={props.transcriptResponse.id} />
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -46,15 +46,10 @@ export default function ShareCopy({
return (
<Box {...boxProps}>
<Button
onClick={onCopyTranscriptClick}
colorScheme="blue"
size={"sm"}
mr={2}
>
<Button onClick={onCopyTranscriptClick} mr={2} variant="subtle">
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</Button>
<Button onClick={onCopySummaryClick} colorScheme="blue" size={"sm"}>
<Button onClick={onCopySummaryClick} variant="subtle">
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</Button>
</Box>

View File

@@ -63,7 +63,7 @@ const ShareLink = (props: ShareLinkProps) => {
onChange={() => {}}
mx="2"
/>
<Button onClick={handleCopyClick} colorScheme="blue">
<Button onClick={handleCopyClick}>
{isCopied ? "Copied!" : "Copy"}
</Button>
</Flex>

View File

@@ -1,8 +1,23 @@
import { useState } from "react";
import { useState, useEffect, useMemo } from "react";
import { featureEnabled } from "../../domainContext";
import ShareModal from "./[transcriptId]/shareModal";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { BoxProps, Button } from "@chakra-ui/react";
import {
BoxProps,
Button,
Dialog,
CloseButton,
Text,
Box,
Flex,
Checkbox,
Combobox,
Spinner,
Portal,
useFilter,
useListCollection,
} from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb";
import useApi from "../../lib/useApi";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
@@ -10,27 +25,251 @@ type ShareZulipProps = {
disabled: boolean;
};
interface Stream {
stream_id: number;
name: string;
}
interface Topic {
name: string;
}
export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const [showModal, setShowModal] = useState(false);
const [stream, setStream] = useState<string | undefined>(undefined);
const [topic, setTopic] = useState<string | undefined>(undefined);
const [includeTopics, setIncludeTopics] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const api = useApi();
const { contains } = useFilter({ sensitivity: "base" });
const {
collection: streamItemsCollection,
filter: streamItemsFilter,
set: streamItemsSet,
} = useListCollection({
items: [],
filter: contains,
});
const {
collection: topicItemsCollection,
filter: topicItemsFilter,
set: topicItemsSet,
} = useListCollection({
items: [],
filter: contains,
});
useEffect(() => {
const fetchZulipStreams = async () => {
if (!api) return;
try {
const response = await api.v1ZulipGetStreams();
setStreams(response);
streamItemsSet(
response.map((stream) => ({
label: stream.name,
value: stream.name,
})),
);
setIsLoading(false);
} catch (error) {
console.error("Error fetching Zulip streams:", error);
}
};
fetchZulipStreams();
}, [!api]);
useEffect(() => {
const fetchZulipTopics = async () => {
if (!api || !stream) return;
try {
const selectedStream = streams.find((s) => s.name === stream);
if (selectedStream) {
const response = await api.v1ZulipGetTopics({
streamId: selectedStream.stream_id,
});
setTopics(response);
topicItemsSet(
response.map((topic) => ({
label: topic.name,
value: topic.name,
})),
);
} else {
topicItemsSet([]);
}
} catch (error) {
console.error("Error fetching Zulip topics:", error);
}
};
fetchZulipTopics();
}, [stream, streams, api]);
const handleSendToZulip = async () => {
if (!api || !props.transcriptResponse) return;
if (stream && topic) {
try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcriptResponse.id,
stream,
topic,
includeTopics,
});
setShowModal(false);
} catch (error) {
console.log(error);
}
}
};
if (!featureEnabled("sendToZulip")) return null;
return (
<>
<Button
colorScheme="blue"
size={"sm"}
isDisabled={props.disabled}
onClick={() => setShowModal(true)}
>
Send to Zulip
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
<TbBrandZulip /> Send to Zulip
</Button>
<ShareModal
transcript={props.transcriptResponse}
topics={props.topicsResponse}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
<Dialog.Root
open={showModal}
onOpenChange={(e) => setShowModal(e.open)}
size="md"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send to Zulip</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{isLoading ? (
<Flex justify="center" py={8}>
<Spinner />
</Flex>
) : (
<>
<Box mb={4}>
<Checkbox.Root
checked={includeTopics}
onCheckedChange={(e) => setIncludeTopics(!!e.checked)}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Include topics</Checkbox.Label>
</Checkbox.Root>
</Box>
<Box mb={4}>
<Flex align="center" gap={2}>
<Text>#</Text>
<Combobox.Root
collection={streamItemsCollection}
value={stream ? [stream] : []}
onValueChange={(e) => {
setTopic(undefined);
setStream(e.value[0]);
}}
onInputValueChange={(e) =>
streamItemsFilter(e.inputValue)
}
openOnClick={true}
positioning={{
strategy: "fixed",
hideWhenDetached: true,
}}
>
<Combobox.Control>
<Combobox.Input placeholder="Pick a stream" />
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No streams found</Combobox.Empty>
{streamItemsCollection.items.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
</Combobox.Item>
))}
</Combobox.Content>
</Combobox.Positioner>
</Combobox.Root>
</Flex>
</Box>
{stream && (
<Box mb={4}>
<Flex align="center" gap={2}>
<Text visibility="hidden">#</Text>
<Combobox.Root
collection={topicItemsCollection}
value={topic ? [topic] : []}
onValueChange={(e) => setTopic(e.value[0])}
onInputValueChange={(e) =>
topicItemsFilter(e.inputValue)
}
openOnClick
selectionBehavior="replace"
skipAnimationOnMount={true}
closeOnSelect={true}
positioning={{
strategy: "fixed",
hideWhenDetached: true,
}}
>
<Combobox.Control>
<Combobox.Input placeholder="Pick a topic" />
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No topics found</Combobox.Empty>
{topicItemsCollection.items.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
<Combobox.ItemIndicator />
</Combobox.Item>
))}
</Combobox.Content>
</Combobox.Positioner>
</Combobox.Root>
</Flex>
</Box>
)}
</>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setShowModal(false)}>
Close
</Button>
<Button disabled={!stream || !topic} onClick={handleSendToZulip}>
Send to Zulip
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi";
import { Heading, IconButton, Input } from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
type TranscriptTitle = {
title: string;
@@ -87,23 +87,26 @@ const TranscriptTitle = (props: TranscriptTitle) => {
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 w-full border-none bg-transparent overflow-hidden h-[fit-content]"
/>
) : (
<>
<Flex alignItems="center">
<Heading
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer"
onClick={handleTitleClick}
cursor={"pointer"}
size={"lg"}
noOfLines={1}
lineClamp={1}
pr={2}
>
{displayedTitle}
</Heading>
<Spacer />
<IconButton
icon={<FaPen />}
aria-label="Edit Transcript Title"
onClick={handleTitleClick}
fontSize={"15px"}
/>
</>
size="sm"
variant="subtle"
>
<LuPen />
</IconButton>
</Flex>
)}
</>
);

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { Page_GetTranscriptMinimal_, SourceKind } from "../../api";
import { PageGetTranscriptMinimal, SourceKind } from "../../api";
type TranscriptList = {
response: Page_GetTranscriptMinimal_ | null;
response: PageGetTranscriptMinimal | null;
loading: boolean;
error: Error | null;
refetch: () => void;
@@ -16,7 +16,7 @@ const useTranscriptList = (
roomId: string | null,
searchTerm: string | null,
): TranscriptList => {
const [response, setResponse] = useState<Page_GetTranscriptMinimal_ | null>(
const [response, setResponse] = useState<PageGetTranscriptMinimal | null>(
null,
);
const [loading, setLoading] = useState<boolean>(true);
@@ -39,6 +39,7 @@ const useTranscriptList = (
sourceKind,
roomId,
searchTerm,
size: 10,
})
.then((response) => {
setResponse(response);
@@ -50,7 +51,7 @@ const useTranscriptList = (
setError(err);
setErrorState(err);
});
}, [!api, page, refetchCount, roomId, searchTerm]);
}, [api, page, refetchCount, roomId, searchTerm, sourceKind]);
return { response, loading, error, refetch };
};

View File

@@ -2,6 +2,6 @@ import { Center, Spinner } from "@chakra-ui/react";
export default () => (
<Center h={14}>
<Spinner speed="1s"></Spinner>
<Spinner />
</Center>
);

View File

@@ -7,7 +7,7 @@ export default function UserInfo() {
const { isLoading, isAuthenticated } = useSessionStatus();
return isLoading ? (
<Spinner size="xs" thickness="1px" className="mx-3" />
<Spinner size="xs" className="mx-3" />
) : !isAuthenticated ? (
<Link
href="/"

View File

@@ -15,9 +15,9 @@ import {
VStack,
HStack,
Spinner,
useToast,
Icon,
} from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
import useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation";
import { notFound } from "next/navigation";
@@ -80,7 +80,6 @@ const useConsentDialog = (
// toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false);
const api = useApi();
const toast = useToast();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
@@ -109,24 +108,23 @@ const useConsentDialog = (
setModalOpen(true);
const TOAST_NEVER_DISMISS_VALUE = null;
const toastId = toast({
position: "top",
duration: TOAST_NEVER_DISMISS_VALUE,
render: ({ onClose }) => {
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => {
const AcceptButton = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
return (
<Button
ref={buttonRef}
colorScheme="blue"
colorPalette="primary"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
onClose();
dismiss();
}}
>
Yes, store the audio
@@ -143,51 +141,58 @@ const useConsentDialog = (
maxW="md"
mx="auto"
>
<VStack spacing={4} align="center">
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack spacing={4} justify="center">
<AcceptButton />
<HStack gap={4} justifyContent="center">
<Button
colorScheme="gray"
variant="ghost"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
onClose();
dismiss();
}}
>
No, delete after transcription
</Button>
<AcceptButton />
</HStack>
</VStack>
</Box>
);
},
onCloseComplete: () => {
setModalOpen(false);
},
});
// Set modal state when toast is dismissed
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, 100);
});
// Handle escape key to close the toast
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toast.close(toastId);
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toast.close(toastId);
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, toast, handleConsent, wherebyRef, modalOpen]);
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
return { showConsentModal, consentState, hasConsent, consentLoading };
};
@@ -212,7 +217,7 @@ function ConsentDialogButton({
top="56px"
left="8px"
zIndex={1000}
colorScheme="blue"
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
@@ -294,13 +299,7 @@ export default function Room(details: RoomDetails) {
bg="gray.50"
p={4}
>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
<Spinner color="blue.500" size="xl" />
</Box>
);
}

View File

@@ -1,37 +0,0 @@
import type { BaseHttpRequest } from "./core/BaseHttpRequest";
import type { OpenAPIConfig } from "./core/OpenAPI";
import { Interceptors } from "./core/OpenAPI";
import { AxiosHttpRequest } from "./core/AxiosHttpRequest";
import { DefaultService } from "./services.gen";
type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest;
export class OpenApi {
public readonly default: DefaultService;
public readonly request: BaseHttpRequest;
constructor(
config?: Partial<OpenAPIConfig>,
HttpRequest: HttpRequestConstructor = AxiosHttpRequest,
) {
this.request = new HttpRequest({
BASE: config?.BASE ?? "",
VERSION: config?.VERSION ?? "0.1.0",
WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false,
CREDENTIALS: config?.CREDENTIALS ?? "include",
TOKEN: config?.TOKEN,
USERNAME: config?.USERNAME,
PASSWORD: config?.PASSWORD,
HEADERS: config?.HEADERS,
ENCODE_PATH: config?.ENCODE_PATH,
interceptors: {
request: config?.interceptors?.request ?? new Interceptors(),
response: config?.interceptors?.response ?? new Interceptors(),
},
});
this.default = new DefaultService(this.request);
}
}

View File

@@ -1,9 +0,0 @@
// NextAuth route handler for Authentik
// Refresh rotation has been taken from https://next-auth.js.org/v3/tutorials/refresh-token-rotation even if we are using 4.x
import NextAuth from "next-auth";
import { authOptions } from "../../../lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

28
www/app/api/client.gen.ts Normal file
View File

@@ -0,0 +1,28 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from "./types.gen";
import {
type Config,
type ClientOptions as DefaultClientOptions,
createClient,
createConfig,
} from "./client";
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
(
override?: Config<DefaultClientOptions & T>,
) => Config<Required<DefaultClientOptions> & T>;
export const client = createClient(
createConfig<ClientOptions>({
baseUrl: "http://127.0.0.1:1250",
}),
);

View File

@@ -0,0 +1,195 @@
import type { Client, Config, RequestOptions } from "./types";
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils";
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<
Request,
Response,
unknown,
RequestOptions
>();
const request: Client["request"] = async (options) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body);
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.body === "") {
opts.headers.delete("Content-Type");
}
const url = buildUrl(opts);
const requestInit: ReqInit = {
redirect: "follow",
...opts,
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request._fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response = await _fetch(request);
for (const fn of interceptors.response._fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
if (
response.status === 204 ||
response.headers.get("Content-Length") === "0"
) {
return opts.responseStyle === "data"
? {}
: {
data: {},
...result,
};
}
const parseAs =
(opts.parseAs === "auto"
? getParseAs(response.headers.get("Content-Type"))
: opts.parseAs) ?? "json";
let data: any;
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "formData":
case "json":
case "text":
data = await response[parseAs]();
break;
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === "data"
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error._fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
...result,
};
};
return {
buildUrl,
connect: (options) => request({ ...options, method: "CONNECT" }),
delete: (options) => request({ ...options, method: "DELETE" }),
get: (options) => request({ ...options, method: "GET" }),
getConfig,
head: (options) => request({ ...options, method: "HEAD" }),
interceptors,
options: (options) => request({ ...options, method: "OPTIONS" }),
patch: (options) => request({ ...options, method: "PATCH" }),
post: (options) => request({ ...options, method: "POST" }),
put: (options) => request({ ...options, method: "PUT" }),
request,
setConfig,
trace: (options) => request({ ...options, method: "TRACE" }),
};
};

View File

@@ -0,0 +1,22 @@
export type { Auth } from "../core/auth";
export type { QuerySerializerOptions } from "../core/bodySerializer";
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer";
export { buildClientParams } from "../core/params";
export { createClient } from "./client";
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
OptionsLegacyParser,
RequestOptions,
RequestResult,
ResponseStyle,
TDataShape,
} from "./types";
export { createConfig, mergeHeaders } from "./utils";

216
www/app/api/client/types.ts Normal file
View File

@@ -0,0 +1,216 @@
import type { Auth } from "../core/auth";
import type { Client as CoreClient, Config as CoreConfig } from "../core/types";
import type { Middleware } from "./utils";
export type ResponseStyle = "data" | "fields";
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, "body" | "headers" | "method">,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T["baseUrl"];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: (request: Request) => ReturnType<typeof fetch>;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?:
| "arrayBuffer"
| "auto"
| "blob"
| "formData"
| "json"
| "stream"
| "text";
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"];
}
export interface RequestOptions<
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true
? Promise<
TResponseStyle extends "data"
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends "data"
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: Pick<TData, "url"> & Options<TData>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
interceptors: Middleware<Request, Response, unknown, RequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<
RequestOptions<TResponseStyle, ThrowOnError>,
"body" | "path" | "query" | "url"
> &
Omit<TData, "url">;
export type OptionsLegacyParser<
TData = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<
RequestOptions<TResponseStyle, ThrowOnError>,
"body" | "headers" | "url"
> &
TData
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "headers">
: TData extends { headers?: any }
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "headers" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "body">
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "url"> & TData;

417
www/app/api/client/utils.ts Normal file
View File

@@ -0,0 +1,417 @@
import { getAuthToken } from "../core/auth";
import type {
QuerySerializer,
QuerySerializerOptions,
} from "../core/bodySerializer";
import { jsonBodySerializer } from "../core/bodySerializer";
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "../core/pathSerializer";
import type { Client, ClientOptions, Config, RequestOptions } from "./types";
interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
const PATH_PARAM_RE = /\{[^{}]+\}/g;
type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
type MatrixStyle = "label" | "matrix" | "simple";
type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = "simple";
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
name = name.substring(1);
style = "label";
} else if (name.startsWith(";")) {
name = name.substring(1);
style = "matrix";
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === "label" ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const createQuerySerializer = <T = unknown>({
allowReserved,
array,
object,
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved,
explode: true,
name,
style: "form",
value,
...array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved,
explode: true,
name,
style: "deepObject",
value: value as Record<string, unknown>,
...object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join("&");
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config["parseAs"], "auto"> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return "stream";
}
const cleanContent = contentType.split(";")[0]?.trim();
if (!cleanContent) {
return;
}
if (
cleanContent.startsWith("application/json") ||
cleanContent.endsWith("+json")
) {
return "json";
}
if (cleanContent === "multipart/form-data") {
return "formData";
}
if (
["application/", "audio/", "image/", "video/"].some((type) =>
cleanContent.startsWith(type),
)
) {
return "blob";
}
if (cleanContent.startsWith("text/")) {
return "text";
}
return;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Headers;
}) => {
for (const auth of security) {
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? "Authorization";
switch (auth.in) {
case "query":
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case "cookie":
options.headers.append("Cookie", `${name}=${token}`);
break;
case "header":
default:
options.headers.set(name, token);
break;
}
return;
}
};
export const buildUrl: Client["buildUrl"] = (options) => {
const url = getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === "function"
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
let url = (baseUrl ?? "") + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : "";
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
export const mergeHeaders = (
...headers: Array<Required<Config>["headers"] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header || typeof header !== "object") {
continue;
}
const iterator =
header instanceof Headers ? header.entries() : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === "object" ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
_fns: (Interceptor | null)[];
constructor() {
this._fns = [];
}
clear() {
this._fns = [];
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") {
return this._fns[id] ? id : -1;
} else {
return this._fns.indexOf(id);
}
}
exists(id: number | Interceptor) {
const index = this.getInterceptorIndex(id);
return !!this._fns[index];
}
eject(id: number | Interceptor) {
const index = this.getInterceptorIndex(id);
if (this._fns[index]) {
this._fns[index] = null;
}
}
update(id: number | Interceptor, fn: Interceptor) {
const index = this.getInterceptorIndex(id);
if (this._fns[index]) {
this._fns[index] = fn;
return id;
} else {
return false;
}
}
use(fn: Interceptor) {
this._fns = [...this._fns, fn];
return this._fns.length - 1;
}
}
// `createInterceptors()` response, meant for external use as it does not
// expose internals
export interface Middleware<Req, Res, Err, Options> {
error: Pick<
Interceptors<ErrInterceptor<Err, Res, Req, Options>>,
"eject" | "use"
>;
request: Pick<Interceptors<ReqInterceptor<Req, Options>>, "eject" | "use">;
response: Pick<
Interceptors<ResInterceptor<Res, Req, Options>>,
"eject" | "use"
>;
}
// do not add `Middleware` as return type so we can use _fns internally
export const createInterceptors = <Req, Res, Err, Options>() => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: "form",
},
object: {
explode: true,
style: "deepObject",
},
});
const defaultHeaders = {
"Content-Type": "application/json",
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: "auto",
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -1,25 +0,0 @@
import type { ApiRequestOptions } from "./ApiRequestOptions";
import type { ApiResult } from "./ApiResult";
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: unknown;
public readonly request: ApiRequestOptions;
constructor(
request: ApiRequestOptions,
response: ApiResult,
message: string,
) {
super(message);
this.name = "ApiError";
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}

View File

@@ -1,21 +0,0 @@
export type ApiRequestOptions<T = unknown> = {
readonly method:
| "GET"
| "PUT"
| "POST"
| "DELETE"
| "OPTIONS"
| "HEAD"
| "PATCH";
readonly url: string;
readonly path?: Record<string, unknown>;
readonly cookies?: Record<string, unknown>;
readonly headers?: Record<string, unknown>;
readonly query?: Record<string, unknown>;
readonly formData?: Record<string, unknown>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly responseTransformer?: (data: unknown) => Promise<T>;
readonly errors?: Record<number | string, string>;
};

View File

@@ -1,7 +0,0 @@
export type ApiResult<TData = any> = {
readonly body: TData;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly url: string;
};

View File

@@ -1,23 +0,0 @@
import type { ApiRequestOptions } from "./ApiRequestOptions";
import { BaseHttpRequest } from "./BaseHttpRequest";
import type { CancelablePromise } from "./CancelablePromise";
import type { OpenAPIConfig } from "./OpenAPI";
import { request as __request } from "./request";
export class AxiosHttpRequest extends BaseHttpRequest {
constructor(config: OpenAPIConfig) {
super(config);
}
/**
* Request method
* @param options The request options from the service
* @returns CancelablePromise<T>
* @throws ApiError
*/
public override request<T>(
options: ApiRequestOptions<T>,
): CancelablePromise<T> {
return __request(this.config, options);
}
}

View File

@@ -1,11 +0,0 @@
import type { ApiRequestOptions } from "./ApiRequestOptions";
import type { CancelablePromise } from "./CancelablePromise";
import type { OpenAPIConfig } from "./OpenAPI";
export abstract class BaseHttpRequest {
constructor(public readonly config: OpenAPIConfig) {}
public abstract request<T>(
options: ApiRequestOptions<T>,
): CancelablePromise<T>;
}

View File

@@ -1,126 +0,0 @@
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = "CancelError";
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
private _isResolved: boolean;
private _isRejected: boolean;
private _isCancelled: boolean;
readonly cancelHandlers: (() => void)[];
readonly promise: Promise<T>;
private _resolve?: (value: T | PromiseLike<T>) => void;
private _reject?: (reason?: unknown) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: unknown) => void,
onCancel: OnCancel,
) => void,
) {
this._isResolved = false;
this._isRejected = false;
this._isCancelled = false;
this.cancelHandlers = [];
this.promise = new Promise<T>((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
return;
}
this._isResolved = true;
if (this._resolve) this._resolve(value);
};
const onReject = (reason?: unknown): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
return;
}
this._isRejected = true;
if (this._reject) this._reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
return;
}
this.cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, "isResolved", {
get: (): boolean => this._isResolved,
});
Object.defineProperty(onCancel, "isRejected", {
get: (): boolean => this._isRejected,
});
Object.defineProperty(onCancel, "isCancelled", {
get: (): boolean => this._isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.promise.finally(onFinally);
}
public cancel(): void {
if (this._isResolved || this._isRejected || this._isCancelled) {
return;
}
this._isCancelled = true;
if (this.cancelHandlers.length) {
try {
for (const cancelHandler of this.cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn("Cancellation threw an error", error);
return;
}
}
this.cancelHandlers.length = 0;
if (this._reject) this._reject(new CancelError("Request aborted"));
}
public get isCancelled(): boolean {
return this._isCancelled;
}
}

View File

@@ -1,57 +0,0 @@
import type { AxiosRequestConfig, AxiosResponse } from "axios";
import type { ApiRequestOptions } from "./ApiRequestOptions";
type Headers = Record<string, string>;
type Middleware<T> = (value: T) => T | Promise<T>;
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>;
export class Interceptors<T> {
_fns: Middleware<T>[];
constructor() {
this._fns = [];
}
eject(fn: Middleware<T>): void {
const index = this._fns.indexOf(fn);
if (index !== -1) {
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)];
}
}
use(fn: Middleware<T>): void {
this._fns = [...this._fns, fn];
}
}
export type OpenAPIConfig = {
BASE: string;
CREDENTIALS: "include" | "omit" | "same-origin";
ENCODE_PATH?: ((path: string) => string) | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
VERSION: string;
WITH_CREDENTIALS: boolean;
interceptors: {
request: Interceptors<AxiosRequestConfig>;
response: Interceptors<AxiosResponse>;
};
};
export const OpenAPI: OpenAPIConfig = {
BASE: "",
CREDENTIALS: "include",
ENCODE_PATH: undefined,
HEADERS: undefined,
PASSWORD: undefined,
TOKEN: undefined,
USERNAME: undefined,
VERSION: "0.1.0",
WITH_CREDENTIALS: false,
interceptors: {
request: new Interceptors(),
response: new Interceptors(),
},
};

40
www/app/api/core/auth.ts Normal file
View File

@@ -0,0 +1,40 @@
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: "header" | "query" | "cookie";
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: "basic" | "bearer";
type: "apiKey" | "http";
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === "function" ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === "bearer") {
return `Bearer ${token}`;
}
if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@@ -0,0 +1,88 @@
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from "./pathSerializer";
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
export interface QuerySerializerOptions {
allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>;
object?: SerializerOptions<ObjectStyle>;
}
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === "string") {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

151
www/app/api/core/params.ts Normal file
View File

@@ -0,0 +1,151 @@
type Slot = "body" | "headers" | "path" | "query";
export type Field =
| {
in: Exclude<Slot, "body">;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, "body">;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
{
in: Slot;
map?: string;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ("in" in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === "object" && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ("in" in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
(params[field.in] as Record<string, unknown>)[name] = arg;
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else {
for (const [slot, allowed] of Object.entries(
config.allowExtra ?? {},
)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@@ -0,0 +1,179 @@
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = "label" | "matrix" | "simple";
export type ObjectStyle = "form" | "deepObject";
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
default:
return "&";
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "form":
return ",";
case "pipeDelimited":
return "|";
case "spaceDelimited":
return "%20";
default:
return ",";
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case "label":
return ".";
case "matrix":
return ";";
case "simple":
return ",";
default:
return "&";
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case "label":
return `.${joinedValues}`;
case "matrix":
return `;${name}=${joinedValues}`;
case "simple":
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === "label" || style === "matrix"
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "object") {
throw new Error(
"Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== "deepObject" && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(",");
switch (style) {
case "form":
return `${name}=${joinedValues}`;
case "label":
return `.${joinedValues}`;
case "matrix":
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === "label" || style === "matrix"
? separator + joinedValues
: joinedValues;
};

View File

@@ -1,387 +0,0 @@
import axios from "axios";
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
AxiosInstance,
} from "axios";
import { ApiError } from "./ApiError";
import type { ApiRequestOptions } from "./ApiRequestOptions";
import type { ApiResult } from "./ApiResult";
import { CancelablePromise } from "./CancelablePromise";
import type { OnCancel } from "./CancelablePromise";
import type { OpenAPIConfig } from "./OpenAPI";
export const isString = (value: unknown): value is string => {
return typeof value === "string";
};
export const isStringWithValue = (value: unknown): value is string => {
return isString(value) && value !== "";
};
export const isBlob = (value: any): value is Blob => {
return value instanceof Blob;
};
export const isFormData = (value: unknown): value is FormData => {
return value instanceof FormData;
};
export const isSuccess = (status: number): boolean => {
return status >= 200 && status < 300;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString("base64");
}
};
export const getQueryString = (params: Record<string, unknown>): string => {
const qs: string[] = [];
const append = (key: string, value: unknown) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const encodePair = (key: string, value: unknown) => {
if (value === undefined || value === null) {
return;
}
if (value instanceof Date) {
append(key, value.toISOString());
} else if (Array.isArray(value)) {
value.forEach((v) => encodePair(key, v));
} else if (typeof value === "object") {
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v));
} else {
append(key, value);
}
};
Object.entries(params).forEach(([key, value]) => encodePair(key, value));
return qs.length ? `?${qs.join("&")}` : "";
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace("{api-version}", config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = config.BASE + path;
return options.query ? url + getQueryString(options.query) : url;
};
export const getFormData = (
options: ApiRequestOptions,
): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: unknown) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([, value]) => value !== undefined && value !== null)
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>;
export const resolve = async <T>(
options: ApiRequestOptions<T>,
resolver?: T | Resolver<T>,
): Promise<T | undefined> => {
if (typeof resolver === "function") {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions<T>,
): Promise<Record<string, string>> => {
const [token, username, password, additionalHeaders] = await Promise.all([
// @ts-ignore
resolve(options, config.TOKEN),
// @ts-ignore
resolve(options, config.USERNAME),
// @ts-ignore
resolve(options, config.PASSWORD),
// @ts-ignore
resolve(options, config.HEADERS),
]);
const headers = Object.entries({
Accept: "application/json",
...additionalHeaders,
...options.headers,
})
.filter(([, value]) => value !== undefined && value !== null)
.reduce(
(headers, [key, value]) => ({
...headers,
[key]: String(value),
}),
{} as Record<string, string>,
);
if (isStringWithValue(token)) {
headers["Authorization"] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers["Authorization"] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers["Content-Type"] = options.mediaType;
} else if (isBlob(options.body)) {
headers["Content-Type"] = options.body.type || "application/octet-stream";
} else if (isString(options.body)) {
headers["Content-Type"] = "text/plain";
} else if (!isFormData(options.body)) {
headers["Content-Type"] = "application/json";
}
} else if (options.formData !== undefined) {
if (options.mediaType) {
headers["Content-Type"] = options.mediaType;
}
}
return headers;
};
export const getRequestBody = (options: ApiRequestOptions): unknown => {
if (options.body) {
return options.body;
}
return undefined;
};
export const sendRequest = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions<T>,
url: string,
body: unknown,
formData: FormData | undefined,
headers: Record<string, string>,
onCancel: OnCancel,
axiosClient: AxiosInstance,
): Promise<AxiosResponse<T>> => {
const controller = new AbortController();
let requestConfig: AxiosRequestConfig = {
data: body ?? formData,
headers,
method: options.method,
signal: controller.signal,
url,
withCredentials: config.WITH_CREDENTIALS,
};
onCancel(() => controller.abort());
for (const fn of config.interceptors.request._fns) {
requestConfig = await fn(requestConfig);
}
try {
return await axiosClient.request(requestConfig);
} catch (error) {
const axiosError = error as AxiosError<T>;
if (axiosError.response) {
return axiosError.response;
}
throw error;
}
};
export const getResponseHeader = (
response: AxiosResponse<unknown>,
responseHeader?: string,
): string | undefined => {
if (responseHeader) {
const content = response.headers[responseHeader];
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = (response: AxiosResponse<unknown>): unknown => {
if (response.status !== 204) {
return response.data;
}
return undefined;
};
export const catchErrorCodes = (
options: ApiRequestOptions,
result: ApiResult,
): void => {
const errors: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "Im a teapot",
421: "Misdirected Request",
422: "Unprocessable Content",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required",
...options.errors,
};
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? "unknown";
const errorStatusText = result.statusText ?? "unknown";
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(
options,
result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @param axiosClient The axios client instance to use
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(
config: OpenAPIConfig,
options: ApiRequestOptions<T>,
axiosClient: AxiosInstance = axios,
): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options);
if (!onCancel.isCancelled) {
let response = await sendRequest<T>(
config,
options,
url,
body,
formData,
headers,
onCancel,
axiosClient,
);
for (const fn of config.interceptors.response._fns) {
response = await fn(response);
}
const responseBody = getResponseBody(response);
const responseHeader = getResponseHeader(
response,
options.responseHeader,
);
let transformedBody = responseBody;
if (options.responseTransformer && isSuccess(response.status)) {
transformedBody = await options.responseTransformer(responseBody);
}
const result: ApiResult = {
url,
ok: isSuccess(response.status),
status: response.status,
statusText: response.statusText,
body: responseHeader ?? transformedBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};

118
www/app/api/core/types.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { Auth, AuthToken } from "./auth";
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from "./bodySerializer";
export interface Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
> {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
connect: MethodFn;
delete: MethodFn;
get: MethodFn;
getConfig: () => Config;
head: MethodFn;
options: MethodFn;
patch: MethodFn;
post: MethodFn;
put: MethodFn;
request: RequestFn;
setConfig: (config: Config) => Config;
trace: MethodFn;
}
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit["headers"]
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?:
| "CONNECT"
| "DELETE"
| "GET"
| "HEAD"
| "OPTIONS"
| "PATCH"
| "POST"
| "PUT"
| "TRACE";
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -1,9 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export { OpenApi } from "./OpenApi";
export { ApiError } from "./core/ApiError";
export { BaseHttpRequest } from "./core/BaseHttpRequest";
export { CancelablePromise, CancelError } from "./core/CancelablePromise";
export { OpenAPI, type OpenAPIConfig } from "./core/OpenAPI";
export * from "./schemas.gen";
export * from "./services.gen";
export * from "./types.gen";
export * from "./sdk.gen";

File diff suppressed because it is too large Load Diff

927
www/app/api/sdk.gen.ts Normal file
View File

@@ -0,0 +1,927 @@
// This file is auto-generated by @hey-api/openapi-ts
import {
type Options as ClientOptions,
type TDataShape,
type Client,
formDataBodySerializer,
} from "./client";
import type {
MetricsData,
MetricsResponses,
V1MeetingAudioConsentData,
V1MeetingAudioConsentResponses,
V1MeetingAudioConsentErrors,
V1RoomsListData,
V1RoomsListResponses,
V1RoomsListErrors,
V1RoomsCreateData,
V1RoomsCreateResponses,
V1RoomsCreateErrors,
V1RoomsDeleteData,
V1RoomsDeleteResponses,
V1RoomsDeleteErrors,
V1RoomsUpdateData,
V1RoomsUpdateResponses,
V1RoomsUpdateErrors,
V1RoomsCreateMeetingData,
V1RoomsCreateMeetingResponses,
V1RoomsCreateMeetingErrors,
V1TranscriptsListData,
V1TranscriptsListResponses,
V1TranscriptsListErrors,
V1TranscriptsCreateData,
V1TranscriptsCreateResponses,
V1TranscriptsCreateErrors,
V1TranscriptDeleteData,
V1TranscriptDeleteResponses,
V1TranscriptDeleteErrors,
V1TranscriptGetData,
V1TranscriptGetResponses,
V1TranscriptGetErrors,
V1TranscriptUpdateData,
V1TranscriptUpdateResponses,
V1TranscriptUpdateErrors,
V1TranscriptGetTopicsData,
V1TranscriptGetTopicsResponses,
V1TranscriptGetTopicsErrors,
V1TranscriptGetTopicsWithWordsData,
V1TranscriptGetTopicsWithWordsResponses,
V1TranscriptGetTopicsWithWordsErrors,
V1TranscriptGetTopicsWithWordsPerSpeakerData,
V1TranscriptGetTopicsWithWordsPerSpeakerResponses,
V1TranscriptGetTopicsWithWordsPerSpeakerErrors,
V1TranscriptPostToZulipData,
V1TranscriptPostToZulipResponses,
V1TranscriptPostToZulipErrors,
V1TranscriptGetAudioMp3Data,
V1TranscriptGetAudioMp3Responses,
V1TranscriptGetAudioMp3Errors,
V1TranscriptHeadAudioMp3Data,
V1TranscriptHeadAudioMp3Responses,
V1TranscriptHeadAudioMp3Errors,
V1TranscriptGetAudioWaveformData,
V1TranscriptGetAudioWaveformResponses,
V1TranscriptGetAudioWaveformErrors,
V1TranscriptGetParticipantsData,
V1TranscriptGetParticipantsResponses,
V1TranscriptGetParticipantsErrors,
V1TranscriptAddParticipantData,
V1TranscriptAddParticipantResponses,
V1TranscriptAddParticipantErrors,
V1TranscriptDeleteParticipantData,
V1TranscriptDeleteParticipantResponses,
V1TranscriptDeleteParticipantErrors,
V1TranscriptGetParticipantData,
V1TranscriptGetParticipantResponses,
V1TranscriptGetParticipantErrors,
V1TranscriptUpdateParticipantData,
V1TranscriptUpdateParticipantResponses,
V1TranscriptUpdateParticipantErrors,
V1TranscriptAssignSpeakerData,
V1TranscriptAssignSpeakerResponses,
V1TranscriptAssignSpeakerErrors,
V1TranscriptMergeSpeakerData,
V1TranscriptMergeSpeakerResponses,
V1TranscriptMergeSpeakerErrors,
V1TranscriptRecordUploadData,
V1TranscriptRecordUploadResponses,
V1TranscriptRecordUploadErrors,
V1TranscriptGetWebsocketEventsData,
V1TranscriptGetWebsocketEventsResponses,
V1TranscriptGetWebsocketEventsErrors,
V1TranscriptRecordWebrtcData,
V1TranscriptRecordWebrtcResponses,
V1TranscriptRecordWebrtcErrors,
V1TranscriptProcessData,
V1TranscriptProcessResponses,
V1TranscriptProcessErrors,
V1UserMeData,
V1UserMeResponses,
V1ZulipGetStreamsData,
V1ZulipGetStreamsResponses,
V1ZulipGetTopicsData,
V1ZulipGetTopicsResponses,
V1ZulipGetTopicsErrors,
V1WherebyWebhookData,
V1WherebyWebhookResponses,
V1WherebyWebhookErrors,
} from "./types.gen";
import { client as _heyApiClient } from "./client.gen";
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
> = ClientOptions<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Metrics
* Endpoint that serves Prometheus metrics.
*/
export const metrics = <ThrowOnError extends boolean = false>(
options?: Options<MetricsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
MetricsResponses,
unknown,
ThrowOnError
>({
url: "/metrics",
...options,
});
};
/**
* Meeting Audio Consent
*/
export const v1MeetingAudioConsent = <ThrowOnError extends boolean = false>(
options: Options<V1MeetingAudioConsentData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1MeetingAudioConsentResponses,
V1MeetingAudioConsentErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/meetings/{meeting_id}/consent",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Rooms List
*/
export const v1RoomsList = <ThrowOnError extends boolean = false>(
options?: Options<V1RoomsListData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
V1RoomsListResponses,
V1RoomsListErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/rooms",
...options,
});
};
/**
* Rooms Create
*/
export const v1RoomsCreate = <ThrowOnError extends boolean = false>(
options: Options<V1RoomsCreateData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1RoomsCreateResponses,
V1RoomsCreateErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/rooms",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Rooms Delete
*/
export const v1RoomsDelete = <ThrowOnError extends boolean = false>(
options: Options<V1RoomsDeleteData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).delete<
V1RoomsDeleteResponses,
V1RoomsDeleteErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/rooms/{room_id}",
...options,
});
};
/**
* Rooms Update
*/
export const v1RoomsUpdate = <ThrowOnError extends boolean = false>(
options: Options<V1RoomsUpdateData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<
V1RoomsUpdateResponses,
V1RoomsUpdateErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/rooms/{room_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Rooms Create Meeting
*/
export const v1RoomsCreateMeeting = <ThrowOnError extends boolean = false>(
options: Options<V1RoomsCreateMeetingData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1RoomsCreateMeetingResponses,
V1RoomsCreateMeetingErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/rooms/{room_name}/meeting",
...options,
});
};
/**
* Transcripts List
*/
export const v1TranscriptsList = <ThrowOnError extends boolean = false>(
options?: Options<V1TranscriptsListData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
V1TranscriptsListResponses,
V1TranscriptsListErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts",
...options,
});
};
/**
* Transcripts Create
*/
export const v1TranscriptsCreate = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptsCreateData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptsCreateResponses,
V1TranscriptsCreateErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Delete
*/
export const v1TranscriptDelete = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptDeleteData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).delete<
V1TranscriptDeleteResponses,
V1TranscriptDeleteErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}",
...options,
});
};
/**
* Transcript Get
*/
export const v1TranscriptGet = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptGetData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetResponses,
V1TranscriptGetErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}",
...options,
});
};
/**
* Transcript Update
*/
export const v1TranscriptUpdate = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptUpdateData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<
V1TranscriptUpdateResponses,
V1TranscriptUpdateErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Get Topics
*/
export const v1TranscriptGetTopics = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptGetTopicsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetTopicsResponses,
V1TranscriptGetTopicsErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/topics",
...options,
});
};
/**
* Transcript Get Topics With Words
*/
export const v1TranscriptGetTopicsWithWords = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetTopicsWithWordsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetTopicsWithWordsResponses,
V1TranscriptGetTopicsWithWordsErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/topics/with-words",
...options,
});
};
/**
* Transcript Get Topics With Words Per Speaker
*/
export const v1TranscriptGetTopicsWithWordsPerSpeaker = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetTopicsWithWordsPerSpeakerData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetTopicsWithWordsPerSpeakerResponses,
V1TranscriptGetTopicsWithWordsPerSpeakerErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
...options,
});
};
/**
* Transcript Post To Zulip
*/
export const v1TranscriptPostToZulip = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptPostToZulipData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptPostToZulipResponses,
V1TranscriptPostToZulipErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/zulip",
...options,
});
};
/**
* Transcript Get Audio Mp3
*/
export const v1TranscriptGetAudioMp3 = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptGetAudioMp3Data, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetAudioMp3Responses,
V1TranscriptGetAudioMp3Errors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/audio/mp3",
...options,
});
};
/**
* Transcript Get Audio Mp3
*/
export const v1TranscriptHeadAudioMp3 = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptHeadAudioMp3Data, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).head<
V1TranscriptHeadAudioMp3Responses,
V1TranscriptHeadAudioMp3Errors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/audio/mp3",
...options,
});
};
/**
* Transcript Get Audio Waveform
*/
export const v1TranscriptGetAudioWaveform = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetAudioWaveformData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetAudioWaveformResponses,
V1TranscriptGetAudioWaveformErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/audio/waveform",
...options,
});
};
/**
* Transcript Get Participants
*/
export const v1TranscriptGetParticipants = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetParticipantsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetParticipantsResponses,
V1TranscriptGetParticipantsErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/participants",
...options,
});
};
/**
* Transcript Add Participant
*/
export const v1TranscriptAddParticipant = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptAddParticipantData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptAddParticipantResponses,
V1TranscriptAddParticipantErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/participants",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Delete Participant
*/
export const v1TranscriptDeleteParticipant = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptDeleteParticipantData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).delete<
V1TranscriptDeleteParticipantResponses,
V1TranscriptDeleteParticipantErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
...options,
});
};
/**
* Transcript Get Participant
*/
export const v1TranscriptGetParticipant = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetParticipantData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetParticipantResponses,
V1TranscriptGetParticipantErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
...options,
});
};
/**
* Transcript Update Participant
*/
export const v1TranscriptUpdateParticipant = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptUpdateParticipantData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<
V1TranscriptUpdateParticipantResponses,
V1TranscriptUpdateParticipantErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Assign Speaker
*/
export const v1TranscriptAssignSpeaker = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptAssignSpeakerData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<
V1TranscriptAssignSpeakerResponses,
V1TranscriptAssignSpeakerErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/speaker/assign",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Merge Speaker
*/
export const v1TranscriptMergeSpeaker = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptMergeSpeakerData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).patch<
V1TranscriptMergeSpeakerResponses,
V1TranscriptMergeSpeakerErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/speaker/merge",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Record Upload
*/
export const v1TranscriptRecordUpload = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptRecordUploadData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptRecordUploadResponses,
V1TranscriptRecordUploadErrors,
ThrowOnError
>({
...formDataBodySerializer,
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/record/upload",
...options,
headers: {
"Content-Type": null,
...options.headers,
},
});
};
/**
* Transcript Get Websocket Events
*/
export const v1TranscriptGetWebsocketEvents = <
ThrowOnError extends boolean = false,
>(
options: Options<V1TranscriptGetWebsocketEventsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1TranscriptGetWebsocketEventsResponses,
V1TranscriptGetWebsocketEventsErrors,
ThrowOnError
>({
url: "/v1/transcripts/{transcript_id}/events",
...options,
});
};
/**
* Transcript Record Webrtc
*/
export const v1TranscriptRecordWebrtc = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptRecordWebrtcData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptRecordWebrtcResponses,
V1TranscriptRecordWebrtcErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/record/webrtc",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Transcript Process
*/
export const v1TranscriptProcess = <ThrowOnError extends boolean = false>(
options: Options<V1TranscriptProcessData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1TranscriptProcessResponses,
V1TranscriptProcessErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/transcripts/{transcript_id}/process",
...options,
});
};
/**
* User Me
*/
export const v1UserMe = <ThrowOnError extends boolean = false>(
options?: Options<V1UserMeData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
V1UserMeResponses,
unknown,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/me",
...options,
});
};
/**
* Zulip Get Streams
* Get all Zulip streams.
*/
export const v1ZulipGetStreams = <ThrowOnError extends boolean = false>(
options?: Options<V1ZulipGetStreamsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
V1ZulipGetStreamsResponses,
unknown,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/zulip/streams",
...options,
});
};
/**
* Zulip Get Topics
* Get all topics for a specific Zulip stream.
*/
export const v1ZulipGetTopics = <ThrowOnError extends boolean = false>(
options: Options<V1ZulipGetTopicsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
V1ZulipGetTopicsResponses,
V1ZulipGetTopicsErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/zulip/streams/{stream_id}/topics",
...options,
});
};
/**
* Whereby Webhook
*/
export const v1WherebyWebhook = <ThrowOnError extends boolean = false>(
options: Options<V1WherebyWebhookData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
V1WherebyWebhookResponses,
V1WherebyWebhookErrors,
ThrowOnError
>({
url: "/v1/whereby",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};

View File

@@ -1,860 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { CancelablePromise } from "./core/CancelablePromise";
import type { BaseHttpRequest } from "./core/BaseHttpRequest";
import type {
MetricsResponse,
V1MeetingAudioConsentData,
V1MeetingAudioConsentResponse,
V1RoomsListData,
V1RoomsListResponse,
V1RoomsCreateData,
V1RoomsCreateResponse,
V1RoomsUpdateData,
V1RoomsUpdateResponse,
V1RoomsDeleteData,
V1RoomsDeleteResponse,
V1RoomsCreateMeetingData,
V1RoomsCreateMeetingResponse,
V1TranscriptsListData,
V1TranscriptsListResponse,
V1TranscriptsCreateData,
V1TranscriptsCreateResponse,
V1TranscriptGetData,
V1TranscriptGetResponse,
V1TranscriptUpdateData,
V1TranscriptUpdateResponse,
V1TranscriptDeleteData,
V1TranscriptDeleteResponse,
V1TranscriptGetTopicsData,
V1TranscriptGetTopicsResponse,
V1TranscriptGetTopicsWithWordsData,
V1TranscriptGetTopicsWithWordsResponse,
V1TranscriptGetTopicsWithWordsPerSpeakerData,
V1TranscriptGetTopicsWithWordsPerSpeakerResponse,
V1TranscriptPostToZulipData,
V1TranscriptPostToZulipResponse,
V1TranscriptHeadAudioMp3Data,
V1TranscriptHeadAudioMp3Response,
V1TranscriptGetAudioMp3Data,
V1TranscriptGetAudioMp3Response,
V1TranscriptGetAudioWaveformData,
V1TranscriptGetAudioWaveformResponse,
V1TranscriptGetParticipantsData,
V1TranscriptGetParticipantsResponse,
V1TranscriptAddParticipantData,
V1TranscriptAddParticipantResponse,
V1TranscriptGetParticipantData,
V1TranscriptGetParticipantResponse,
V1TranscriptUpdateParticipantData,
V1TranscriptUpdateParticipantResponse,
V1TranscriptDeleteParticipantData,
V1TranscriptDeleteParticipantResponse,
V1TranscriptAssignSpeakerData,
V1TranscriptAssignSpeakerResponse,
V1TranscriptMergeSpeakerData,
V1TranscriptMergeSpeakerResponse,
V1TranscriptRecordUploadData,
V1TranscriptRecordUploadResponse,
V1TranscriptGetWebsocketEventsData,
V1TranscriptGetWebsocketEventsResponse,
V1TranscriptRecordWebrtcData,
V1TranscriptRecordWebrtcResponse,
V1TranscriptProcessData,
V1TranscriptProcessResponse,
V1UserMeResponse,
V1ZulipGetStreamsResponse,
V1ZulipGetTopicsData,
V1ZulipGetTopicsResponse,
V1WherebyWebhookData,
V1WherebyWebhookResponse,
} from "./types.gen";
export class DefaultService {
constructor(public readonly httpRequest: BaseHttpRequest) {}
/**
* Metrics
* Endpoint that serves Prometheus metrics.
* @returns unknown Successful Response
* @throws ApiError
*/
public metrics(): CancelablePromise<MetricsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/metrics",
});
}
/**
* Meeting Audio Consent
* @param data The data for the request.
* @param data.meetingId
* @param data.requestBody
* @returns unknown Successful Response
* @throws ApiError
*/
public v1MeetingAudioConsent(
data: V1MeetingAudioConsentData,
): CancelablePromise<V1MeetingAudioConsentResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/meetings/{meeting_id}/consent",
path: {
meeting_id: data.meetingId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms List
* @param data The data for the request.
* @param data.page Page number
* @param data.size Page size
* @returns Page_Room_ Successful Response
* @throws ApiError
*/
public v1RoomsList(
data: V1RoomsListData = {},
): CancelablePromise<V1RoomsListResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms",
query: {
page: data.page,
size: data.size,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Create
* @param data The data for the request.
* @param data.requestBody
* @returns Room Successful Response
* @throws ApiError
*/
public v1RoomsCreate(
data: V1RoomsCreateData,
): CancelablePromise<V1RoomsCreateResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms",
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Update
* @param data The data for the request.
* @param data.roomId
* @param data.requestBody
* @returns Room Successful Response
* @throws ApiError
*/
public v1RoomsUpdate(
data: V1RoomsUpdateData,
): CancelablePromise<V1RoomsUpdateResponse> {
return this.httpRequest.request({
method: "PATCH",
url: "/v1/rooms/{room_id}",
path: {
room_id: data.roomId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Delete
* @param data The data for the request.
* @param data.roomId
* @returns DeletionStatus Successful Response
* @throws ApiError
*/
public v1RoomsDelete(
data: V1RoomsDeleteData,
): CancelablePromise<V1RoomsDeleteResponse> {
return this.httpRequest.request({
method: "DELETE",
url: "/v1/rooms/{room_id}",
path: {
room_id: data.roomId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Create Meeting
* @param data The data for the request.
* @param data.roomName
* @returns Meeting Successful Response
* @throws ApiError
*/
public v1RoomsCreateMeeting(
data: V1RoomsCreateMeetingData,
): CancelablePromise<V1RoomsCreateMeetingResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms/{room_name}/meeting",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcripts List
* @param data The data for the request.
* @param data.sourceKind
* @param data.roomId
* @param data.searchTerm
* @param data.page Page number
* @param data.size Page size
* @returns Page_GetTranscriptMinimal_ Successful Response
* @throws ApiError
*/
public v1TranscriptsList(
data: V1TranscriptsListData = {},
): CancelablePromise<V1TranscriptsListResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts",
query: {
source_kind: data.sourceKind,
room_id: data.roomId,
search_term: data.searchTerm,
page: data.page,
size: data.size,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcripts Create
* @param data The data for the request.
* @param data.requestBody
* @returns GetTranscript Successful Response
* @throws ApiError
*/
public v1TranscriptsCreate(
data: V1TranscriptsCreateData,
): CancelablePromise<V1TranscriptsCreateResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts",
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get
* @param data The data for the request.
* @param data.transcriptId
* @returns GetTranscript Successful Response
* @throws ApiError
*/
public v1TranscriptGet(
data: V1TranscriptGetData,
): CancelablePromise<V1TranscriptGetResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Update
* @param data The data for the request.
* @param data.transcriptId
* @param data.requestBody
* @returns GetTranscript Successful Response
* @throws ApiError
*/
public v1TranscriptUpdate(
data: V1TranscriptUpdateData,
): CancelablePromise<V1TranscriptUpdateResponse> {
return this.httpRequest.request({
method: "PATCH",
url: "/v1/transcripts/{transcript_id}",
path: {
transcript_id: data.transcriptId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Delete
* @param data The data for the request.
* @param data.transcriptId
* @returns DeletionStatus Successful Response
* @throws ApiError
*/
public v1TranscriptDelete(
data: V1TranscriptDeleteData,
): CancelablePromise<V1TranscriptDeleteResponse> {
return this.httpRequest.request({
method: "DELETE",
url: "/v1/transcripts/{transcript_id}",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Topics
* @param data The data for the request.
* @param data.transcriptId
* @returns GetTranscriptTopic Successful Response
* @throws ApiError
*/
public v1TranscriptGetTopics(
data: V1TranscriptGetTopicsData,
): CancelablePromise<V1TranscriptGetTopicsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/topics",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Topics With Words
* @param data The data for the request.
* @param data.transcriptId
* @returns GetTranscriptTopicWithWords Successful Response
* @throws ApiError
*/
public v1TranscriptGetTopicsWithWords(
data: V1TranscriptGetTopicsWithWordsData,
): CancelablePromise<V1TranscriptGetTopicsWithWordsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/topics/with-words",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Topics With Words Per Speaker
* @param data The data for the request.
* @param data.transcriptId
* @param data.topicId
* @returns GetTranscriptTopicWithWordsPerSpeaker Successful Response
* @throws ApiError
*/
public v1TranscriptGetTopicsWithWordsPerSpeaker(
data: V1TranscriptGetTopicsWithWordsPerSpeakerData,
): CancelablePromise<V1TranscriptGetTopicsWithWordsPerSpeakerResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
path: {
transcript_id: data.transcriptId,
topic_id: data.topicId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Post To Zulip
* @param data The data for the request.
* @param data.transcriptId
* @param data.stream
* @param data.topic
* @param data.includeTopics
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptPostToZulip(
data: V1TranscriptPostToZulipData,
): CancelablePromise<V1TranscriptPostToZulipResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts/{transcript_id}/zulip",
path: {
transcript_id: data.transcriptId,
},
query: {
stream: data.stream,
topic: data.topic,
include_topics: data.includeTopics,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Audio Mp3
* @param data The data for the request.
* @param data.transcriptId
* @param data.token
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptHeadAudioMp3(
data: V1TranscriptHeadAudioMp3Data,
): CancelablePromise<V1TranscriptHeadAudioMp3Response> {
return this.httpRequest.request({
method: "HEAD",
url: "/v1/transcripts/{transcript_id}/audio/mp3",
path: {
transcript_id: data.transcriptId,
},
query: {
token: data.token,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Audio Mp3
* @param data The data for the request.
* @param data.transcriptId
* @param data.token
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptGetAudioMp3(
data: V1TranscriptGetAudioMp3Data,
): CancelablePromise<V1TranscriptGetAudioMp3Response> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/audio/mp3",
path: {
transcript_id: data.transcriptId,
},
query: {
token: data.token,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Audio Waveform
* @param data The data for the request.
* @param data.transcriptId
* @returns AudioWaveform Successful Response
* @throws ApiError
*/
public v1TranscriptGetAudioWaveform(
data: V1TranscriptGetAudioWaveformData,
): CancelablePromise<V1TranscriptGetAudioWaveformResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/audio/waveform",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Participants
* @param data The data for the request.
* @param data.transcriptId
* @returns Participant Successful Response
* @throws ApiError
*/
public v1TranscriptGetParticipants(
data: V1TranscriptGetParticipantsData,
): CancelablePromise<V1TranscriptGetParticipantsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/participants",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Add Participant
* @param data The data for the request.
* @param data.transcriptId
* @param data.requestBody
* @returns Participant Successful Response
* @throws ApiError
*/
public v1TranscriptAddParticipant(
data: V1TranscriptAddParticipantData,
): CancelablePromise<V1TranscriptAddParticipantResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts/{transcript_id}/participants",
path: {
transcript_id: data.transcriptId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Participant
* @param data The data for the request.
* @param data.transcriptId
* @param data.participantId
* @returns Participant Successful Response
* @throws ApiError
*/
public v1TranscriptGetParticipant(
data: V1TranscriptGetParticipantData,
): CancelablePromise<V1TranscriptGetParticipantResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
path: {
transcript_id: data.transcriptId,
participant_id: data.participantId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Update Participant
* @param data The data for the request.
* @param data.transcriptId
* @param data.participantId
* @param data.requestBody
* @returns Participant Successful Response
* @throws ApiError
*/
public v1TranscriptUpdateParticipant(
data: V1TranscriptUpdateParticipantData,
): CancelablePromise<V1TranscriptUpdateParticipantResponse> {
return this.httpRequest.request({
method: "PATCH",
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
path: {
transcript_id: data.transcriptId,
participant_id: data.participantId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Delete Participant
* @param data The data for the request.
* @param data.transcriptId
* @param data.participantId
* @returns DeletionStatus Successful Response
* @throws ApiError
*/
public v1TranscriptDeleteParticipant(
data: V1TranscriptDeleteParticipantData,
): CancelablePromise<V1TranscriptDeleteParticipantResponse> {
return this.httpRequest.request({
method: "DELETE",
url: "/v1/transcripts/{transcript_id}/participants/{participant_id}",
path: {
transcript_id: data.transcriptId,
participant_id: data.participantId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Assign Speaker
* @param data The data for the request.
* @param data.transcriptId
* @param data.requestBody
* @returns SpeakerAssignmentStatus Successful Response
* @throws ApiError
*/
public v1TranscriptAssignSpeaker(
data: V1TranscriptAssignSpeakerData,
): CancelablePromise<V1TranscriptAssignSpeakerResponse> {
return this.httpRequest.request({
method: "PATCH",
url: "/v1/transcripts/{transcript_id}/speaker/assign",
path: {
transcript_id: data.transcriptId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Merge Speaker
* @param data The data for the request.
* @param data.transcriptId
* @param data.requestBody
* @returns SpeakerAssignmentStatus Successful Response
* @throws ApiError
*/
public v1TranscriptMergeSpeaker(
data: V1TranscriptMergeSpeakerData,
): CancelablePromise<V1TranscriptMergeSpeakerResponse> {
return this.httpRequest.request({
method: "PATCH",
url: "/v1/transcripts/{transcript_id}/speaker/merge",
path: {
transcript_id: data.transcriptId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Record Upload
* @param data The data for the request.
* @param data.transcriptId
* @param data.chunkNumber
* @param data.totalChunks
* @param data.formData
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptRecordUpload(
data: V1TranscriptRecordUploadData,
): CancelablePromise<V1TranscriptRecordUploadResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts/{transcript_id}/record/upload",
path: {
transcript_id: data.transcriptId,
},
query: {
chunk_number: data.chunkNumber,
total_chunks: data.totalChunks,
},
formData: data.formData,
mediaType: "multipart/form-data",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Get Websocket Events
* @param data The data for the request.
* @param data.transcriptId
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptGetWebsocketEvents(
data: V1TranscriptGetWebsocketEventsData,
): CancelablePromise<V1TranscriptGetWebsocketEventsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/transcripts/{transcript_id}/events",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Record Webrtc
* @param data The data for the request.
* @param data.transcriptId
* @param data.requestBody
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptRecordWebrtc(
data: V1TranscriptRecordWebrtcData,
): CancelablePromise<V1TranscriptRecordWebrtcResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts/{transcript_id}/record/webrtc",
path: {
transcript_id: data.transcriptId,
},
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Transcript Process
* @param data The data for the request.
* @param data.transcriptId
* @returns unknown Successful Response
* @throws ApiError
*/
public v1TranscriptProcess(
data: V1TranscriptProcessData,
): CancelablePromise<V1TranscriptProcessResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/transcripts/{transcript_id}/process",
path: {
transcript_id: data.transcriptId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* User Me
* @returns unknown Successful Response
* @throws ApiError
*/
public v1UserMe(): CancelablePromise<V1UserMeResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/me",
});
}
/**
* Zulip Get Streams
* Get all Zulip streams.
* @returns Stream Successful Response
* @throws ApiError
*/
public v1ZulipGetStreams(): CancelablePromise<V1ZulipGetStreamsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/zulip/streams",
});
}
/**
* Zulip Get Topics
* Get all topics for a specific Zulip stream.
* @param data The data for the request.
* @param data.streamId
* @returns Topic Successful Response
* @throws ApiError
*/
public v1ZulipGetTopics(
data: V1ZulipGetTopicsData,
): CancelablePromise<V1ZulipGetTopicsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/zulip/streams/{stream_id}/topics",
path: {
stream_id: data.streamId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Whereby Webhook
* @param data The data for the request.
* @param data.requestBody
* @returns unknown Successful Response
* @throws ApiError
*/
public v1WherebyWebhook(
data: V1WherebyWebhookData,
): CancelablePromise<V1WherebyWebhookResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/whereby",
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { createPortal } from "react-dom";
import { Box } from "@chakra-ui/react";
interface ToastOptions {
placement?: string;
duration?: number | null;
render: (props: { dismiss: () => void }) => React.ReactNode;
}
interface Toast extends ToastOptions {
id: string;
}
interface ToasterContextType {
toasts: Toast[];
addToast: (options: ToastOptions) => string;
removeToast: (id: string) => void;
}
const ToasterContext = createContext<ToasterContextType | null>(null);
export const ToasterProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((options: ToastOptions) => {
const id = String(Date.now() + Math.random());
setToasts((prev) => [...prev, { ...options, id }]);
if (options.duration !== null) {
setTimeout(() => {
removeToast(id);
}, options.duration || 5000);
}
return id;
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
return (
<ToasterContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer />
</ToasterContext.Provider>
);
};
const ToastContainer = () => {
const context = useContext(ToasterContext);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!context || !mounted) return null;
return createPortal(
<Box
position="fixed"
top="20px"
left="50%"
transform="translateX(-50%)"
zIndex={9999}
pointerEvents="none"
>
{context.toasts.map((toast) => (
<Box key={toast.id} mb={3} pointerEvents="auto">
{toast.render({ dismiss: () => context.removeToast(toast.id) })}
</Box>
))}
</Box>,
document.body,
);
};
class ToasterClass {
private listeners: ((action: { type: string; payload: any }) => void)[] = [];
private nextId = 1;
private toastsMap: Map<string, boolean> = new Map();
subscribe(listener: (action: { type: string; payload: any }) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
private notify(action: { type: string; payload: any }) {
this.listeners.forEach((listener) => listener(action));
}
create(options: ToastOptions): Promise<string> {
const id = String(this.nextId++);
this.toastsMap.set(id, true);
this.notify({ type: "ADD_TOAST", payload: { ...options, id } });
if (options.duration !== null) {
setTimeout(() => {
this.dismiss(id);
}, options.duration || 5000);
}
return Promise.resolve(id);
}
dismiss(id: string) {
this.toastsMap.delete(id);
this.notify({ type: "REMOVE_TOAST", payload: id });
}
isActive(id: string): boolean {
return this.toastsMap.has(id);
}
}
export const toaster = new ToasterClass();
// Bridge component to connect the class-based API with React
export const Toaster = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => {
const unsubscribe = toaster.subscribe((action) => {
if (action.type === "ADD_TOAST") {
setToasts((prev) => [...prev, action.payload]);
} else if (action.type === "REMOVE_TOAST") {
setToasts((prev) =>
prev.filter((toast) => toast.id !== action.payload),
);
}
});
return unsubscribe;
}, []);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(
<Box
position="fixed"
top="20px"
left="50%"
transform="translateX(-50%)"
zIndex={9999}
pointerEvents="none"
>
{toasts.map((toast) => (
<Box key={toast.id} mb={3} pointerEvents="auto">
{toast.render({ dismiss: () => toaster.dismiss(toast.id) })}
</Box>
))}
</Box>,
document.body,
);
};

View File

@@ -1,5 +1,6 @@
import "./styles/globals.scss";
import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google";
import SessionProvider from "./lib/SessionProvider";
import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage";
@@ -9,6 +10,12 @@ import { getConfig } from "./lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
const poppins = Poppins({
subsets: ["latin"],
weight: ["200", "400", "600"],
display: "swap",
});
export const viewport: Viewport = {
themeColor: "black",
width: "device-width",
@@ -65,7 +72,7 @@ export default async function RootLayout({
const config = await getConfig();
return (
<html lang="en">
<html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
<SessionProvider>
<DomainContextProvider config={config}>

View File

@@ -1,7 +1,8 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, useToast, Text } from "@chakra-ui/react";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
interface WherebyEmbedProps {
roomUrl: string;
@@ -16,34 +17,31 @@ export default function WherebyWebinarEmbed({
const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
const toast = useToast();
useEffect(() => {
if (roomUrl && !localStorage.getItem("recording-notice-dismissed")) {
const toastId = toast({
position: "top",
const toastIdPromise = toaster.create({
placement: "top",
duration: null,
render: ({ onClose }) => (
render: ({ dismiss }) => (
<Box p={4} bg="white" borderRadius="md" boxShadow="md">
<HStack justify="space-between" align="center">
<HStack justifyContent="space-between" alignItems="center">
<Text>
This webinar is being recorded. By continuing, you agree to our{" "}
<Button
as="a"
<Link
href="https://monadical.com/privacy"
variant="link"
color="blue.600"
textDecoration="underline"
target="_blank"
>
Privacy Policy
</Button>
</Link>
</Text>
<Button
size="sm"
variant="ghost"
onClick={() => {
localStorage.setItem("recording-notice-dismissed", "true");
onClose();
dismiss();
}}
>
@@ -54,10 +52,10 @@ export default function WherebyWebinarEmbed({
});
return () => {
toast.close(toastId);
toastIdPromise.then((id) => toaster.dismiss(id));
};
}
}, [roomUrl, toast]);
}, [roomUrl]);
const handleLeave = () => {
if (onLeave) {

View File

@@ -26,13 +26,13 @@ export const ExpandableText = forwardRef<HTMLDivElement, Props>(
return (
<Box ref={ref} {...rest}>
<Box ref={inputRef} noOfLines={expandedCount}>
<Box ref={inputRef} lineClamp={expandedCount}>
{children}
</Box>
<Button
display={isTextClamped ? "block" : "none"}
size="sm"
variant="link"
variant="ghost"
onClick={handleToggle}
mt={2}
>

View File

@@ -1,14 +1,18 @@
"use client";
import { ChakraProvider } from "@chakra-ui/react";
import theme from "./styles/theme";
import system from "./styles/theme";
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
import { Toaster } from "./components/ui/toaster";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ChakraProvider theme={theme}>
<WherebyProvider>{children}</WherebyProvider>
<ChakraProvider value={system}>
<WherebyProvider>
{children}
<Toaster />
</WherebyProvider>
</ChakraProvider>
);
}

View File

@@ -1,120 +0,0 @@
/* Define basic button styles */
input[type="button"],
button {
/* Reset default button styles */
border: none;
background-color: transparent;
font-family: inherit;
padding: 0;
/* Visual */
border-radius: 8px;
/* Size */
padding: 0.4em 1em;
min-height: 44px;
/* Text */
text-align: center;
line-height: 1.1;
/* Display */
display: inline-flex;
align-items: center;
justify-content: center;
/* Animation */
transition: 220ms all ease-in-out;
}
button:focus-visible {
outline-style: none;
}
@media (max-width: 768px) {
input[type="button"],
button {
padding: 0.25em 0.75em;
min-height: 30px;
}
}
/* Button modifiers */
input[type="button"].small,
button.small {
font-size: 1.15rem;
}
input[type="button"].block,
button.block {
width: 100%;
}
/* Disabled styles */
/* input[type="button"][disabled], */
/* button[disabled] { */
/* border-color: #ccc; */
/* background: #b8b8b8 !important; */
/* cursor: not-allowed; */
/* } */
/**/
/* input[type="button"][disabled]:hover, */
/* button[disabled]:hover { */
/* background: #b8b8b8 !important; */
/* cursor: not-allowed !important; */
/* } */
/* Red button states */
input[type="button"][data-color="red"],
button[data-color="red"],
input[type="button"][data-color="red"]:hover,
button[data-color="red"]:hover,
input[type="button"][data-color="red"]:active,
button[data-color="red"]:active {
background-color: #cc3347;
}
/* Green button states */
input[type="button"][data-color="green"],
button[data-color="green"],
input[type="button"][data-color="green"]:hover,
button[data-color="green"]:hover,
input[type="button"][data-color="green"]:active,
button[data-color="green"]:active {
background-color: #33cc47;
}
/* Blue button states */
input[type="button"][data-color="blue"],
button[data-color="blue"],
input[type="button"][data-color="blue"]:hover,
button[data-color="blue"]:hover,
input[type="button"][data-color="blue"]:active,
button[data-color="blue"]:active {
background-color: #3347cc;
}
/* Orange button states */
input[type="button"][data-color="orange"],
button[data-color="orange"],
input[type="button"][data-color="orange"]:hover,
button[data-color="orange"]:hover,
input[type="button"][data-color="orange"]:active,
button[data-color="orange"]:active {
background-color: #ff7f00;
}
/* Purple button states */
input[type="button"][data-color="purple"],
button[data-color="purple"],
input[type="button"][data-color="purple"]:hover,
button[data-color="purple"]:hover,
input[type="button"][data-color="purple"]:active,
button[data-color="purple"]:active {
background-color: #800080;
}
/* Yellow button states */
input[type="button"][data-color="yellow"],
button[data-color="yellow"],
input[type="button"][data-color="yellow"]:hover,
button[data-color="yellow"]:hover,
input[type="button"][data-color="yellow"]:active,
button[data-color="yellow"]:active {
background-color: #ffff00;
}

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