mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 20:59:05 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43ea9349f5 | |||
|
|
b3a8e9739d | ||
|
|
369ecdff13 | ||
| fc363bd49b | |||
|
|
962038ee3f | ||
|
|
3b85ff3bdf | ||
|
|
cde99ca271 | ||
|
|
f81fe9948a | ||
|
|
5a5b323382 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* replace nextjs-config with environment variables ([#632](https://github.com/Monadical-SAS/reflector/issues/632)) ([369ecdf](https://github.com/Monadical-SAS/reflector/commit/369ecdff13f3862d926a9c0b87df52c9d94c4dde))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* anonymous users transcript permissions ([#621](https://github.com/Monadical-SAS/reflector/issues/621)) ([f81fe99](https://github.com/Monadical-SAS/reflector/commit/f81fe9948a9237b3e0001b2d8ca84f54d76878f9))
|
||||
* auth post ([#624](https://github.com/Monadical-SAS/reflector/issues/624)) ([cde99ca](https://github.com/Monadical-SAS/reflector/commit/cde99ca2716f84ba26798f289047732f0448742e))
|
||||
* auth post ([#626](https://github.com/Monadical-SAS/reflector/issues/626)) ([3b85ff3](https://github.com/Monadical-SAS/reflector/commit/3b85ff3bdf4fb053b103070646811bc990c0e70a))
|
||||
* auth post ([#627](https://github.com/Monadical-SAS/reflector/issues/627)) ([962038e](https://github.com/Monadical-SAS/reflector/commit/962038ee3f2a555dc3c03856be0e4409456e0996))
|
||||
* missing follow_redirects=True on modal endpoint ([#630](https://github.com/Monadical-SAS/reflector/issues/630)) ([fc363bd](https://github.com/Monadical-SAS/reflector/commit/fc363bd49b17b075e64f9186e5e0185abc325ea7))
|
||||
* sync backend and frontend token refresh logic ([#614](https://github.com/Monadical-SAS/reflector/issues/614)) ([5a5b323](https://github.com/Monadical-SAS/reflector/commit/5a5b3233820df9536da75e87ce6184a983d4713a))
|
||||
|
||||
## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ pnpm install
|
||||
|
||||
# Copy configuration templates
|
||||
cp .env_template .env
|
||||
cp config-template.ts config.ts
|
||||
```
|
||||
|
||||
**Development:**
|
||||
|
||||
36
README.md
36
README.md
@@ -99,11 +99,10 @@ Start with `cd www`.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env_template .env
|
||||
cp config-template.ts config.ts
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip.
|
||||
Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip.
|
||||
|
||||
**Run in development mode**
|
||||
|
||||
@@ -168,3 +167,34 @@ You can manually process an audio file by calling the process tool:
|
||||
```bash
|
||||
uv run python -m reflector.tools.process path/to/audio.wav
|
||||
```
|
||||
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes.
|
||||
|
||||
### Available Feature Flags
|
||||
|
||||
| Feature Flag | Environment Variable |
|
||||
|-------------|---------------------|
|
||||
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
|
||||
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
|
||||
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
|
||||
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
|
||||
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
|
||||
|
||||
### Setting Feature Flags
|
||||
|
||||
Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Enable user authentication requirement
|
||||
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||
|
||||
# Disable browse functionality
|
||||
NEXT_PUBLIC_FEATURE_BROWSE=false
|
||||
|
||||
# Enable Zulip integration
|
||||
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||
```
|
||||
|
||||
@@ -2,7 +2,6 @@ from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from reflector.db import get_database, metadata
|
||||
@@ -178,23 +177,6 @@ class MeetingController:
|
||||
return None
|
||||
return Meeting(**result)
|
||||
|
||||
async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting:
|
||||
"""
|
||||
Get a meeting by ID for HTTP request.
|
||||
|
||||
If not found, it will raise a 404 error.
|
||||
"""
|
||||
query = meetings.select().where(meetings.c.id == meeting_id)
|
||||
result = await get_database().fetch_one(query)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
|
||||
meeting = Meeting(**result)
|
||||
if result["user_id"] != user_id:
|
||||
meeting.host_room_url = ""
|
||||
|
||||
return meeting
|
||||
|
||||
async def update_meeting(self, meeting_id: str, **kwargs):
|
||||
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
|
||||
await get_database().execute(query)
|
||||
|
||||
@@ -23,7 +23,7 @@ from pydantic import (
|
||||
|
||||
from reflector.db import get_database
|
||||
from reflector.db.rooms import rooms
|
||||
from reflector.db.transcripts import SourceKind, transcripts
|
||||
from reflector.db.transcripts import SourceKind, TranscriptStatus, transcripts
|
||||
from reflector.db.utils import is_postgresql
|
||||
from reflector.logger import logger
|
||||
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
|
||||
@@ -161,7 +161,7 @@ class SearchResult(BaseModel):
|
||||
room_name: str | None = None
|
||||
source_kind: SourceKind
|
||||
created_at: datetime
|
||||
status: str = Field(..., min_length=1)
|
||||
status: TranscriptStatus = Field(..., min_length=1)
|
||||
rank: float = Field(..., ge=0, le=1)
|
||||
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
|
||||
search_snippets: list[str] = Field(
|
||||
|
||||
@@ -47,6 +47,7 @@ class FileDiarizationModalProcessor(FileDiarizationProcessor):
|
||||
"audio_file_url": data.audio_url,
|
||||
"timestamp": 0,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
diarization_data = response.json()["diarization"]
|
||||
|
||||
@@ -54,6 +54,7 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
|
||||
"language": data.language,
|
||||
"batch": True,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pydantic.types import PositiveInt
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from reflector.utils.string import NonEmptyString
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -120,7 +122,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# Whereby integration
|
||||
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
|
||||
WHEREBY_API_KEY: str | None = None
|
||||
WHEREBY_API_KEY: NonEmptyString | None = None
|
||||
WHEREBY_WEBHOOK_SECRET: str | None = None
|
||||
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
|
||||
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
|
||||
|
||||
@@ -10,8 +10,11 @@ NonEmptyString = Annotated[
|
||||
non_empty_string_adapter = TypeAdapter(NonEmptyString)
|
||||
|
||||
|
||||
def parse_non_empty_string(s: str) -> NonEmptyString:
|
||||
return non_empty_string_adapter.validate_python(s)
|
||||
def parse_non_empty_string(s: str, error: str | None = None) -> NonEmptyString:
|
||||
try:
|
||||
return non_empty_string_adapter.validate_python(s)
|
||||
except Exception as e:
|
||||
raise ValueError(f"{e}: {error}" if error else e) from e
|
||||
|
||||
|
||||
def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
|
||||
|
||||
@@ -215,14 +215,10 @@ async def rooms_create_meeting(
|
||||
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"],
|
||||
"Race condition detected for room %s and meeting %s - fetching existing meeting",
|
||||
room.name,
|
||||
whereby_meeting["meetingId"],
|
||||
)
|
||||
|
||||
# Fetch the meeting that was created by the other request
|
||||
@@ -232,7 +228,9 @@ async def rooms_create_meeting(
|
||||
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
|
||||
"Meeting disappeared after race condition for room %s",
|
||||
room.name,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Unable to join meeting - please try again"
|
||||
|
||||
@@ -350,8 +350,6 @@ async def transcript_update(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
values = info.dict(exclude_unset=True)
|
||||
updated_transcript = await transcripts_controller.update(transcript, values)
|
||||
return updated_transcript
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.string import parse_non_empty_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_headers():
|
||||
api_key = parse_non_empty_string(
|
||||
settings.WHEREBY_API_KEY, "WHEREBY_API_KEY value is required."
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
|
||||
HEADERS = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": f"Bearer {settings.WHEREBY_API_KEY}",
|
||||
}
|
||||
TIMEOUT = 10 # seconds
|
||||
|
||||
|
||||
def _get_whereby_s3_auth():
|
||||
errors = []
|
||||
try:
|
||||
bucket_name = parse_non_empty_string(
|
||||
settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||
"RECORDING_STORAGE_AWS_BUCKET_NAME value is required.",
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
try:
|
||||
key_id = parse_non_empty_string(
|
||||
settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||
"AWS_WHEREBY_ACCESS_KEY_ID value is required.",
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
try:
|
||||
key_secret = parse_non_empty_string(
|
||||
settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||
"AWS_WHEREBY_ACCESS_KEY_SECRET value is required.",
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
if len(errors) > 0:
|
||||
raise Exception(
|
||||
f"Failed to get Whereby auth settings: {', '.join(str(e) for e in errors)}"
|
||||
)
|
||||
return bucket_name, key_id, key_secret
|
||||
|
||||
|
||||
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
||||
s3_bucket_name, s3_key_id, s3_key_secret = _get_whereby_s3_auth()
|
||||
data = {
|
||||
"isLocked": room.is_locked,
|
||||
"roomNamePrefix": room_name_prefix,
|
||||
@@ -23,23 +65,26 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
||||
"type": room.recording_type,
|
||||
"destination": {
|
||||
"provider": "s3",
|
||||
"bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||
"accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||
"accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||
"bucket": s3_bucket_name,
|
||||
"accessKeyId": s3_key_id,
|
||||
"accessKeySecret": s3_key_secret,
|
||||
"fileFormat": "mp4",
|
||||
},
|
||||
"startTrigger": room.recording_trigger,
|
||||
},
|
||||
"fields": ["hostRoomUrl"],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{settings.WHEREBY_API_URL}/meetings",
|
||||
headers=HEADERS,
|
||||
headers=_get_headers(),
|
||||
json=data,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
if response.status_code == 403:
|
||||
logger.warning(
|
||||
f"Failed to create meeting: access denied on Whereby: {response.text}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -48,7 +93,7 @@ async def get_room_sessions(room_name: str):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
|
||||
headers=HEADERS,
|
||||
headers=_get_headers(),
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match():
|
||||
"id": test_id,
|
||||
"name": "Empty Transcript",
|
||||
"title": "Empty Meeting",
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"locked": False,
|
||||
"duration": 0.0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
@@ -109,7 +109,7 @@ async def test_search_with_long_summary():
|
||||
"id": test_id,
|
||||
"name": "Test Long Summary",
|
||||
"title": "Regular Meeting",
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"locked": False,
|
||||
"duration": 1800.0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
@@ -165,7 +165,7 @@ async def test_postgresql_search_with_data():
|
||||
"id": test_id,
|
||||
"name": "Test Search Transcript",
|
||||
"title": "Engineering Planning Meeting Q4 2024",
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"locked": False,
|
||||
"duration": 1800.0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
@@ -221,7 +221,7 @@ We need to implement PostgreSQL tsvector for better performance.""",
|
||||
test_result = next((r for r in results if r.id == test_id), None)
|
||||
if test_result:
|
||||
assert test_result.title == "Engineering Planning Meeting Q4 2024"
|
||||
assert test_result.status == "completed"
|
||||
assert test_result.status == "ended"
|
||||
assert test_result.duration == 1800.0
|
||||
assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1"
|
||||
|
||||
@@ -268,7 +268,7 @@ def mock_db_result():
|
||||
"title": "Test Transcript",
|
||||
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
|
||||
"duration": 3600.0,
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"user_id": "test-user",
|
||||
"room_id": "room1",
|
||||
"source_kind": SourceKind.LIVE,
|
||||
@@ -433,7 +433,7 @@ class TestSearchResultModel:
|
||||
room_id="room-456",
|
||||
source_kind=SourceKind.ROOM,
|
||||
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
|
||||
status="completed",
|
||||
status="ended",
|
||||
rank=0.85,
|
||||
duration=1800.5,
|
||||
search_snippets=["snippet 1", "snippet 2"],
|
||||
@@ -443,7 +443,7 @@ class TestSearchResultModel:
|
||||
assert result.title == "Test Title"
|
||||
assert result.user_id == "user-123"
|
||||
assert result.room_id == "room-456"
|
||||
assert result.status == "completed"
|
||||
assert result.status == "ended"
|
||||
assert result.rank == 0.85
|
||||
assert result.duration == 1800.5
|
||||
assert len(result.search_snippets) == 2
|
||||
@@ -474,7 +474,7 @@ class TestSearchResultModel:
|
||||
id="test-id",
|
||||
source_kind=SourceKind.LIVE,
|
||||
created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc),
|
||||
status="completed",
|
||||
status="ended",
|
||||
rank=0.9,
|
||||
duration=None,
|
||||
search_snippets=[],
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization():
|
||||
"id": test_id,
|
||||
"name": "Test Snippet Priority",
|
||||
"title": "Meeting About Projects",
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"locked": False,
|
||||
"duration": 1800.0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
@@ -106,7 +106,7 @@ async def test_long_summary_only_search():
|
||||
"id": test_id,
|
||||
"name": "Test Long Only",
|
||||
"title": "Standard Meeting",
|
||||
"status": "completed",
|
||||
"status": "ended",
|
||||
"locked": False,
|
||||
"duration": 1800.0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
|
||||
34
www/.env.example
Normal file
34
www/.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Environment
|
||||
ENVIRONMENT=development
|
||||
NEXT_PUBLIC_ENV=development
|
||||
|
||||
# Site Configuration
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
# Nextauth envs
|
||||
# not used in app code but in lib code
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=your-nextauth-secret-here
|
||||
# / Nextauth envs
|
||||
|
||||
# Authentication (Authentik OAuth/OIDC)
|
||||
AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector
|
||||
AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/
|
||||
AUTHENTIK_CLIENT_ID=your-client-id-here
|
||||
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
|
||||
|
||||
# Feature Flags
|
||||
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||
# NEXT_PUBLIC_FEATURE_PRIVACY=false
|
||||
# NEXT_PUBLIC_FEATURE_BROWSE=true
|
||||
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||
# NEXT_PUBLIC_FEATURE_ROOMS=true
|
||||
|
||||
# API URLs
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
|
||||
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
|
||||
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
|
||||
|
||||
# Sentry
|
||||
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||
1
www/.gitignore
vendored
1
www/.gitignore
vendored
@@ -40,7 +40,6 @@ next-env.d.ts
|
||||
# Sentry Auth Token
|
||||
.sentryclirc
|
||||
|
||||
config.ts
|
||||
|
||||
# openapi logs
|
||||
openapi-ts-error-*.log
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
FaMicrophone,
|
||||
FaGear,
|
||||
} from "react-icons/fa6";
|
||||
import { TranscriptStatus } from "../../../lib/transcript";
|
||||
|
||||
interface TranscriptStatusIconProps {
|
||||
status: string;
|
||||
status: TranscriptStatus;
|
||||
}
|
||||
|
||||
export default function TranscriptStatusIcon({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Container, Flex, Link } from "@chakra-ui/react";
|
||||
import { getConfig } from "../lib/edgeConfig";
|
||||
import { featureEnabled } from "../lib/features";
|
||||
import NextLink from "next/link";
|
||||
import Image from "next/image";
|
||||
import UserInfo from "../(auth)/userInfo";
|
||||
@@ -11,8 +11,6 @@ export default async function AppLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const config = await getConfig();
|
||||
const { requireLogin, privacy, browse, rooms } = config.features;
|
||||
return (
|
||||
<Container
|
||||
minW="100vw"
|
||||
@@ -58,7 +56,7 @@ export default async function AppLayout({
|
||||
>
|
||||
Create
|
||||
</Link>
|
||||
{browse ? (
|
||||
{featureEnabled("browse") ? (
|
||||
<>
|
||||
·
|
||||
<Link href="/browse" as={NextLink} className="font-light px-2">
|
||||
@@ -68,7 +66,7 @@ export default async function AppLayout({
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{rooms ? (
|
||||
{featureEnabled("rooms") ? (
|
||||
<>
|
||||
·
|
||||
<Link href="/rooms" as={NextLink} className="font-light px-2">
|
||||
@@ -78,7 +76,7 @@ export default async function AppLayout({
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{requireLogin ? (
|
||||
{featureEnabled("requireLogin") ? (
|
||||
<>
|
||||
·
|
||||
<UserInfo />
|
||||
|
||||
@@ -3,8 +3,10 @@ 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";
|
||||
import { TranscriptStatus } from "../../../../lib/transcript";
|
||||
|
||||
import { featureEnabled } from "../../../../lib/features";
|
||||
|
||||
type TopicListProps = {
|
||||
topics: Topic[];
|
||||
@@ -14,7 +16,7 @@ type TopicListProps = {
|
||||
];
|
||||
autoscroll: boolean;
|
||||
transcriptId: string;
|
||||
status: string;
|
||||
status: TranscriptStatus | null;
|
||||
currentTranscriptText: any;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import ParticipantList from "./participantList";
|
||||
import type { components } from "../../../../reflector-api";
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
import { SelectedText, selectedTextIsTimeSlice } from "./types";
|
||||
import { useTranscriptUpdate } from "../../../../lib/apiHooks";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import {
|
||||
useTranscriptGet,
|
||||
useTranscriptUpdate,
|
||||
} from "../../../../lib/apiHooks";
|
||||
import { useError } from "../../../../(errors)/errorContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Grid } from "@chakra-ui/react";
|
||||
@@ -25,7 +27,7 @@ export default function TranscriptCorrect({
|
||||
params: { transcriptId },
|
||||
}: TranscriptCorrect) {
|
||||
const updateTranscriptMutation = useTranscriptUpdate();
|
||||
const transcript = useTranscript(transcriptId);
|
||||
const transcript = useTranscriptGet(transcriptId);
|
||||
const stateCurrentTopic = useState<GetTranscriptTopic>();
|
||||
const [currentTopic, _sct] = stateCurrentTopic;
|
||||
const stateSelectedText = useState<SelectedText>();
|
||||
@@ -36,7 +38,7 @@ export default function TranscriptCorrect({
|
||||
const router = useRouter();
|
||||
|
||||
const markAsDone = async () => {
|
||||
if (transcript.response && !transcript.response.reviewed) {
|
||||
if (transcript.data && !transcript.data.reviewed) {
|
||||
try {
|
||||
await updateTranscriptMutation.mutateAsync({
|
||||
params: {
|
||||
@@ -114,7 +116,7 @@ export default function TranscriptCorrect({
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{transcript.response && !transcript.response?.reviewed && (
|
||||
{transcript.data && !transcript.data?.reviewed && (
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="p-2 px-4 rounded bg-green-400"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import Modal from "../modal";
|
||||
import useTranscript from "../useTranscript";
|
||||
import useTopics from "../useTopics";
|
||||
import useWaveform from "../useWaveform";
|
||||
import useMp3 from "../useMp3";
|
||||
@@ -12,6 +11,8 @@ import TranscriptTitle from "../transcriptTitle";
|
||||
import Player from "../player";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
|
||||
import { useTranscriptGet } from "../../../lib/apiHooks";
|
||||
import { TranscriptStatus } from "../../../lib/transcript";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
@@ -22,11 +23,15 @@ type TranscriptDetails = {
|
||||
export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
const transcriptId = details.params.transcriptId;
|
||||
const router = useRouter();
|
||||
const statusToRedirect = ["idle", "recording", "processing"];
|
||||
const statusToRedirect = [
|
||||
"idle",
|
||||
"recording",
|
||||
"processing",
|
||||
] satisfies TranscriptStatus[] as TranscriptStatus[];
|
||||
|
||||
const transcript = useTranscript(transcriptId);
|
||||
const transcriptStatus = transcript.response?.status;
|
||||
const waiting = statusToRedirect.includes(transcriptStatus || "");
|
||||
const transcript = useTranscriptGet(transcriptId);
|
||||
const waiting =
|
||||
transcript.data && statusToRedirect.includes(transcript.data.status);
|
||||
|
||||
const mp3 = useMp3(transcriptId, waiting);
|
||||
const topics = useTopics(transcriptId);
|
||||
@@ -56,7 +61,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
if (transcript?.loading || topics?.loading) {
|
||||
if (transcript?.isLoading || topics?.loading) {
|
||||
return <Modal title="Loading" text={"Loading transcript..."} />;
|
||||
}
|
||||
|
||||
@@ -86,7 +91,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
useActiveTopic={useActiveTopic}
|
||||
waveform={waveform.waveform}
|
||||
media={mp3.media}
|
||||
mediaDuration={transcript.response?.duration || null}
|
||||
mediaDuration={transcript.data?.duration || null}
|
||||
/>
|
||||
) : !mp3.loading && (waveform.error || mp3.error) ? (
|
||||
<Box p={4} bg="red.100" borderRadius="md">
|
||||
@@ -116,10 +121,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
<Flex direction="column" gap={0}>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<TranscriptTitle
|
||||
title={transcript.response?.title || "Unnamed Transcript"}
|
||||
title={transcript.data?.title || "Unnamed Transcript"}
|
||||
transcriptId={transcriptId}
|
||||
onUpdate={(newTitle) => {
|
||||
transcript.reload();
|
||||
transcript.refetch().then(() => {});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -136,23 +141,23 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
useActiveTopic={useActiveTopic}
|
||||
autoscroll={false}
|
||||
transcriptId={transcriptId}
|
||||
status={transcript.response?.status}
|
||||
status={transcript.data?.status || null}
|
||||
currentTranscriptText=""
|
||||
/>
|
||||
{transcript.response && topics.topics ? (
|
||||
{transcript.data && topics.topics ? (
|
||||
<>
|
||||
<FinalSummary
|
||||
transcriptResponse={transcript.response}
|
||||
transcriptResponse={transcript.data}
|
||||
topicsResponse={topics.topics}
|
||||
onUpdate={(newSummary) => {
|
||||
transcript.reload();
|
||||
onUpdate={() => {
|
||||
transcript.refetch();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Flex justify={"center"} alignItems={"center"} h={"100%"}>
|
||||
<div className="flex flex-col h-full justify-center content-center">
|
||||
{transcript.response.status == "processing" ? (
|
||||
{transcript?.data?.status == "processing" ? (
|
||||
<Text>Loading Transcript</Text>
|
||||
) : (
|
||||
<Text>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Recorder from "../../recorder";
|
||||
import { TopicList } from "../_components/TopicList";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useWebSockets } from "../../useWebSockets";
|
||||
import { Topic } from "../../webSocketTypes";
|
||||
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
|
||||
@@ -11,6 +10,8 @@ import useMp3 from "../../useMp3";
|
||||
import WaveformLoading from "../../waveformLoading";
|
||||
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
|
||||
import LiveTrancription from "../../liveTranscription";
|
||||
import { useTranscriptGet } from "../../../../lib/apiHooks";
|
||||
import { TranscriptStatus } from "../../../../lib/transcript";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
@@ -19,7 +20,7 @@ type TranscriptDetails = {
|
||||
};
|
||||
|
||||
const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
const transcript = useTranscript(details.params.transcriptId);
|
||||
const transcript = useTranscriptGet(details.params.transcriptId);
|
||||
const [transcriptStarted, setTranscriptStarted] = useState(false);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
|
||||
@@ -29,8 +30,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState(
|
||||
webSockets.status.value || transcript.response?.status || "idle",
|
||||
const [status, setStatus] = useState<TranscriptStatus>(
|
||||
webSockets.status?.value || transcript.data?.status || "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +42,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
useEffect(() => {
|
||||
//TODO HANDLE ERROR STATUS BETTER
|
||||
const newStatus =
|
||||
webSockets.status.value || transcript.response?.status || "idle";
|
||||
webSockets.status?.value || transcript.data?.status || "idle";
|
||||
setStatus(newStatus);
|
||||
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
|
||||
console.log(newStatus, "redirecting");
|
||||
@@ -49,7 +50,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
const newUrl = "/transcripts/" + details.params.transcriptId;
|
||||
router.replace(newUrl);
|
||||
}
|
||||
}, [webSockets.status.value, transcript.response?.status]);
|
||||
}, [webSockets.status?.value, transcript.data?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useWebSockets } from "../../useWebSockets";
|
||||
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useMp3 from "../../useMp3";
|
||||
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
|
||||
import FileUploadButton from "../../fileUploadButton";
|
||||
import { useTranscriptGet } from "../../../../lib/apiHooks";
|
||||
|
||||
type TranscriptUpload = {
|
||||
params: {
|
||||
@@ -15,7 +15,7 @@ type TranscriptUpload = {
|
||||
};
|
||||
|
||||
const TranscriptUpload = (details: TranscriptUpload) => {
|
||||
const transcript = useTranscript(details.params.transcriptId);
|
||||
const transcript = useTranscriptGet(details.params.transcriptId);
|
||||
const [transcriptStarted, setTranscriptStarted] = useState(false);
|
||||
|
||||
const webSockets = useWebSockets(details.params.transcriptId);
|
||||
@@ -25,13 +25,13 @@ const TranscriptUpload = (details: TranscriptUpload) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [status_, setStatus] = useState(
|
||||
webSockets.status.value || transcript.response?.status || "idle",
|
||||
webSockets.status?.value || transcript.data?.status || "idle",
|
||||
);
|
||||
|
||||
// status is obviously done if we have transcript
|
||||
const status =
|
||||
!transcript.loading && transcript.response?.status === "ended"
|
||||
? transcript.response?.status
|
||||
!transcript.isLoading && transcript.data?.status === "ended"
|
||||
? transcript.data?.status
|
||||
: status_;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,9 +43,9 @@ const TranscriptUpload = (details: TranscriptUpload) => {
|
||||
//TODO HANDLE ERROR STATUS BETTER
|
||||
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
|
||||
const newStatus =
|
||||
transcript.response?.status === "ended"
|
||||
transcript.data?.status === "ended"
|
||||
? "ended"
|
||||
: webSockets.status.value || transcript.response?.status || "idle";
|
||||
: webSockets.status?.value || transcript.data?.status || "idle";
|
||||
setStatus(newStatus);
|
||||
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
|
||||
console.log(newStatus, "redirecting");
|
||||
@@ -53,7 +53,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
|
||||
const newUrl = "/transcripts/" + details.params.transcriptId;
|
||||
router.replace(newUrl);
|
||||
}
|
||||
}, [webSockets.status.value, transcript.response?.status]);
|
||||
}, [webSockets.status?.value, transcript.data?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
|
||||
import useCreateTranscript from "../createTranscript";
|
||||
import SelectSearch from "react-select-search";
|
||||
import { supportedLanguages } from "../../../supportedLanguages";
|
||||
import { featureEnabled } from "../../../domainContext";
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
@@ -21,10 +20,9 @@ import {
|
||||
Spacer,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAuth } from "../../../lib/AuthProvider";
|
||||
import type { components } from "../../../reflector-api";
|
||||
import { featureEnabled } from "../../../lib/features";
|
||||
|
||||
const TranscriptCreate = () => {
|
||||
const isClient = typeof window !== "undefined";
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const isAuthenticated = auth.status === "authenticated";
|
||||
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
|
||||
placeholder="Choose your language"
|
||||
/>
|
||||
</Box>
|
||||
{isClient && !loading ? (
|
||||
{!loading ? (
|
||||
permissionOk ? (
|
||||
<Spacer />
|
||||
) : permissionDenied ? (
|
||||
|
||||
@@ -11,10 +11,11 @@ import useAudioDevice from "./useAudioDevice";
|
||||
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
|
||||
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
import { TranscriptStatus } from "../../lib/transcript";
|
||||
|
||||
type RecorderProps = {
|
||||
transcriptId: string;
|
||||
status: string;
|
||||
status: TranscriptStatus;
|
||||
};
|
||||
|
||||
export default function Recorder(props: RecorderProps) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { featureEnabled } from "../../domainContext";
|
||||
|
||||
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
||||
import type { components } from "../../reflector-api";
|
||||
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
|
||||
import ShareZulip from "./shareZulip";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
|
||||
import { featureEnabled } from "../../lib/features";
|
||||
|
||||
type ShareAndPrivacyProps = {
|
||||
finalSummaryRef: any;
|
||||
transcriptResponse: GetTranscript;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, use } from "react";
|
||||
import { featureEnabled } from "../../domainContext";
|
||||
import { Button, Flex, Input, Text } from "@chakra-ui/react";
|
||||
import QRCode from "react-qr-code";
|
||||
|
||||
import { featureEnabled } from "../../lib/features";
|
||||
|
||||
type ShareLinkProps = {
|
||||
transcriptId: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { featureEnabled } from "../../domainContext";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
@@ -25,6 +24,8 @@ import {
|
||||
useTranscriptPostToZulip,
|
||||
} from "../../lib/apiHooks";
|
||||
|
||||
import { featureEnabled } from "../../lib/features";
|
||||
|
||||
type ShareZulipProps = {
|
||||
transcriptResponse: GetTranscript;
|
||||
topicsResponse: GetTranscriptTopic[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { DomainContext } from "../../domainContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranscriptGet } from "../../lib/apiHooks";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { API_URL } from "../../lib/apiClient";
|
||||
|
||||
export type Mp3Response = {
|
||||
media: HTMLMediaElement | null;
|
||||
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
null,
|
||||
);
|
||||
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
||||
const { api_url } = useContext(DomainContext);
|
||||
const auth = useAuth();
|
||||
const accessTokenInfo =
|
||||
auth.status === "authenticated" ? auth.accessToken : null;
|
||||
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
|
||||
// Audio is not deleted, proceed to load it
|
||||
audioElement = document.createElement("audio");
|
||||
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||
audioElement.crossOrigin = "anonymous";
|
||||
audioElement.preload = "auto";
|
||||
|
||||
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
if (handleError) audioElement.removeEventListener("error", handleError);
|
||||
}
|
||||
};
|
||||
}, [transcriptId, transcript, later, api_url]);
|
||||
}, [transcriptId, transcript, later]);
|
||||
|
||||
const getNow = () => {
|
||||
setLater(false);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useTranscriptGet } from "../../lib/apiHooks";
|
||||
|
||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||
|
||||
type ErrorTranscript = {
|
||||
error: Error;
|
||||
loading: false;
|
||||
response: null;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
type LoadingTranscript = {
|
||||
response: null;
|
||||
loading: true;
|
||||
error: false;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
type SuccessTranscript = {
|
||||
response: GetTranscript;
|
||||
loading: false;
|
||||
error: null;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const useTranscript = (
|
||||
id: string | null,
|
||||
): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
|
||||
const { data, isLoading, error, refetch } = useTranscriptGet(id);
|
||||
|
||||
// Map to the expected return format
|
||||
if (isLoading) {
|
||||
return {
|
||||
response: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
reload: refetch,
|
||||
};
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: error as Error,
|
||||
loading: false,
|
||||
response: null,
|
||||
reload: refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if data is undefined or null
|
||||
if (!data) {
|
||||
return {
|
||||
response: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
reload: refetch,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: data,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTranscript;
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { DomainContext } from "../../domainContext";
|
||||
import type { components } from "../../reflector-api";
|
||||
type AudioWaveform = components["schemas"]["AudioWaveform"];
|
||||
type GetTranscriptSegmentTopic =
|
||||
components["schemas"]["GetTranscriptSegmentTopic"];
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { $api } from "../../lib/apiClient";
|
||||
import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
|
||||
|
||||
export type UseWebSockets = {
|
||||
transcriptTextLive: string;
|
||||
@@ -16,7 +15,7 @@ export type UseWebSockets = {
|
||||
title: string;
|
||||
topics: Topic[];
|
||||
finalSummary: FinalSummary;
|
||||
status: Status;
|
||||
status: Status | null;
|
||||
waveform: AudioWaveform | null;
|
||||
duration: number | null;
|
||||
};
|
||||
@@ -34,10 +33,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
const [finalSummary, setFinalSummary] = useState<FinalSummary>({
|
||||
summary: "",
|
||||
});
|
||||
const [status, setStatus] = useState<Status>({ value: "" });
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const { setError } = useError();
|
||||
|
||||
const { websocket_url: websocketUrl } = useContext(DomainContext);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [accumulatedText, setAccumulatedText] = useState<string>("");
|
||||
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
|
||||
if (!transcriptId) return;
|
||||
|
||||
const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`;
|
||||
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
|
||||
let ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [transcriptId, websocketUrl]);
|
||||
}, [transcriptId]);
|
||||
|
||||
return {
|
||||
transcriptTextLive,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { components } from "../../reflector-api";
|
||||
import type { TranscriptStatus } from "../../lib/transcript";
|
||||
|
||||
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
|
||||
|
||||
@@ -13,7 +14,7 @@ export type FinalSummary = {
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
value: string;
|
||||
value: TranscriptStatus;
|
||||
};
|
||||
|
||||
export type TranslatedTopic = {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { DomainConfig } from "./lib/edgeConfig";
|
||||
|
||||
type DomainContextType = Omit<DomainConfig, "auth_callback_url">;
|
||||
|
||||
export const DomainContext = createContext<DomainContextType>({
|
||||
features: {
|
||||
requireLogin: false,
|
||||
privacy: true,
|
||||
browse: false,
|
||||
sendToZulip: false,
|
||||
},
|
||||
api_url: "",
|
||||
websocket_url: "",
|
||||
});
|
||||
|
||||
export const DomainContextProvider = ({
|
||||
config,
|
||||
children,
|
||||
}: {
|
||||
config: DomainConfig;
|
||||
children: any;
|
||||
}) => {
|
||||
const [context, setContext] = useState<DomainContextType>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
const { auth_callback_url, ...others } = config;
|
||||
setContext(others);
|
||||
}, [config]);
|
||||
|
||||
if (!context) return;
|
||||
|
||||
return (
|
||||
<DomainContext.Provider value={context}>{children}</DomainContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Get feature config client-side with
|
||||
export const featureEnabled = (
|
||||
featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
|
||||
) => {
|
||||
const context = useContext(DomainContext);
|
||||
|
||||
return context.features[featureName] as boolean | undefined;
|
||||
};
|
||||
|
||||
// Get config server-side (out of react) : see lib/edgeConfig.
|
||||
@@ -3,9 +3,7 @@ import { Metadata, Viewport } from "next";
|
||||
import { Poppins } from "next/font/google";
|
||||
import { ErrorProvider } from "./(errors)/errorContext";
|
||||
import ErrorMessage from "./(errors)/errorMessage";
|
||||
import { DomainContextProvider } from "./domainContext";
|
||||
import { RecordingConsentProvider } from "./recordingConsentContext";
|
||||
import { getConfig } from "./lib/edgeConfig";
|
||||
import { ErrorBoundary } from "@sentry/nextjs";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
@@ -68,21 +66,17 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const config = await getConfig();
|
||||
|
||||
return (
|
||||
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
||||
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
||||
<DomainContextProvider config={config}>
|
||||
<RecordingConsentProvider>
|
||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||
<ErrorProvider>
|
||||
<ErrorMessage />
|
||||
<Providers>{children}</Providers>
|
||||
</ErrorProvider>
|
||||
</ErrorBoundary>
|
||||
</RecordingConsentProvider>
|
||||
</DomainContextProvider>
|
||||
<RecordingConsentProvider>
|
||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||
<ErrorProvider>
|
||||
<ErrorMessage />
|
||||
<Providers>{children}</Providers>
|
||||
</ErrorProvider>
|
||||
</ErrorBoundary>
|
||||
</RecordingConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -88,8 +88,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
// not useEffect, we need it ASAP
|
||||
// apparently, still no guarantee this code runs before mutations are fired
|
||||
configureApiAuth(
|
||||
contextValue.status === "authenticated" ? contextValue.accessToken : null,
|
||||
contextValue.status === "authenticated"
|
||||
? contextValue.accessToken
|
||||
: contextValue.status === "loading"
|
||||
? undefined
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
|
||||
|
||||
const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
import { shouldRefreshToken } from "./auth";
|
||||
|
||||
export function SessionAutoRefresh({ children }) {
|
||||
const auth = useAuth();
|
||||
@@ -25,8 +23,7 @@ export function SessionAutoRefresh({ children }) {
|
||||
const INTERVAL_REFRESH_MS = 5000;
|
||||
const interval = setInterval(() => {
|
||||
if (accessTokenExpires === null) return;
|
||||
const timeLeft = accessTokenExpires - Date.now();
|
||||
if (timeLeft < REFRESH_BEFORE) {
|
||||
if (shouldRefreshToken(accessTokenExpires)) {
|
||||
auth
|
||||
.update()
|
||||
.then(() => {})
|
||||
|
||||
@@ -2,32 +2,43 @@
|
||||
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "../reflector-api";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import createFetchClient from "openapi-react-query";
|
||||
import { assertExistsAndNonEmptyString } from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
const API_URL = !isBuildPhase
|
||||
export const API_URL = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
|
||||
: "http://localhost";
|
||||
|
||||
// Create the base openapi-fetch client with a default URL
|
||||
// The actual URL will be set via middleware in AuthProvider
|
||||
// TODO decide strict validation or not
|
||||
export const WEBSOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
|
||||
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
});
|
||||
|
||||
export const $api = createFetchClient<paths>(client);
|
||||
|
||||
let currentAuthToken: string | null | undefined = null;
|
||||
const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
|
||||
let tries = 0;
|
||||
let time = 0;
|
||||
const STEP = 100;
|
||||
while (currentAuthToken === undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, STEP));
|
||||
time += STEP;
|
||||
tries++;
|
||||
// most likely first try is more than enough, if it's more there's already something weird happens
|
||||
if (tries > 10) {
|
||||
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
|
||||
throw new Error(
|
||||
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.use({
|
||||
onRequest({ request }) {
|
||||
async onRequest({ request }) {
|
||||
await waitForAuthTokenDefinitivePresenceOrAbscence();
|
||||
if (currentAuthToken) {
|
||||
request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
|
||||
}
|
||||
@@ -44,7 +55,13 @@ client.use({
|
||||
},
|
||||
});
|
||||
|
||||
export const $api = createFetchClient<paths>(client);
|
||||
|
||||
let currentAuthToken: string | null | undefined = undefined;
|
||||
|
||||
// the function contract: lightweight, idempotent
|
||||
export const configureApiAuth = (token: string | null | undefined) => {
|
||||
// watch only for the initial loading; "reloading" state assumes token presence/absence
|
||||
if (token === undefined && currentAuthToken !== undefined) return;
|
||||
currentAuthToken = token;
|
||||
};
|
||||
|
||||
@@ -96,8 +96,6 @@ export function useTranscriptProcess() {
|
||||
}
|
||||
|
||||
export function useTranscriptGet(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}",
|
||||
@@ -109,7 +107,7 @@ export function useTranscriptGet(transcriptId: string | null) {
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
enabled: !!transcriptId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -292,18 +290,16 @@ export function useTranscriptUploadAudio() {
|
||||
}
|
||||
|
||||
export function useTranscriptWaveform(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/audio/waveform",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
path: { transcript_id: transcriptId! },
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
enabled: !!transcriptId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -316,7 +312,7 @@ export function useTranscriptMP3(transcriptId: string | null) {
|
||||
"/v1/transcripts/{transcript_id}/audio/mp3",
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId || "" },
|
||||
path: { transcript_id: transcriptId! },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -326,8 +322,6 @@ export function useTranscriptMP3(transcriptId: string | null) {
|
||||
}
|
||||
|
||||
export function useTranscriptTopics(transcriptId: string | null) {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
return $api.useQuery(
|
||||
"get",
|
||||
"/v1/transcripts/{transcript_id}/topics",
|
||||
@@ -337,7 +331,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!transcriptId && isAuthenticated,
|
||||
enabled: !!transcriptId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { assertExistsAndNonEmptyString } from "./utils";
|
||||
|
||||
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
||||
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
|
||||
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
||||
|
||||
export const shouldRefreshToken = (accessTokenExpires: number): boolean => {
|
||||
const timeLeft = accessTokenExpires - Date.now();
|
||||
return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
};
|
||||
|
||||
export const LOGIN_REQUIRED_PAGES = [
|
||||
"/transcripts/[!new]",
|
||||
"/browse(.*)",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
REFRESH_ACCESS_TOKEN_BEFORE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR,
|
||||
shouldRefreshToken,
|
||||
} from "./auth";
|
||||
import {
|
||||
getTokenCache,
|
||||
@@ -85,9 +86,13 @@ export const authOptions: AuthOptions = {
|
||||
"currentToken from cache",
|
||||
JSON.stringify(currentToken, null, 2),
|
||||
"will be returned?",
|
||||
currentToken && Date.now() < currentToken.token.accessTokenExpires,
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires),
|
||||
);
|
||||
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
|
||||
if (
|
||||
currentToken &&
|
||||
!shouldRefreshToken(currentToken.token.accessTokenExpires)
|
||||
) {
|
||||
return currentToken.token;
|
||||
}
|
||||
|
||||
@@ -128,7 +133,7 @@ async function lockedRefreshAccessToken(
|
||||
if (cached) {
|
||||
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
} else if (Date.now() < cached.token.accessTokenExpires) {
|
||||
} else if (!shouldRefreshToken(cached.token.accessTokenExpires)) {
|
||||
console.debug("returning cached token", cached.token);
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
type EdgeConfig = {
|
||||
[domainWithDash: string]: {
|
||||
features: {
|
||||
[featureName in
|
||||
| "requireLogin"
|
||||
| "privacy"
|
||||
| "browse"
|
||||
| "sendToZulip"]: boolean;
|
||||
};
|
||||
auth_callback_url: string;
|
||||
websocket_url: string;
|
||||
api_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DomainConfig = EdgeConfig["domainWithDash"];
|
||||
|
||||
// Edge config main keys can only be alphanumeric and _ or -
|
||||
export function edgeKeyToDomain(key: string) {
|
||||
return key.replaceAll("_", ".");
|
||||
}
|
||||
|
||||
export function edgeDomainToKey(domain: string) {
|
||||
return domain.replaceAll(".", "_");
|
||||
}
|
||||
|
||||
// get edge config server-side (prefer DomainContext when available), domain is the hostname
|
||||
export async function getConfig() {
|
||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
||||
try {
|
||||
return require("../../config").localConfig;
|
||||
} catch (e) {
|
||||
// next build() WILL try to execute the require above even if conditionally protected
|
||||
// but thank god it at least runs catch{} block properly
|
||||
if (!isBuildPhase) throw new Error(e);
|
||||
return require("../../config-template").localConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
||||
let config = await get(edgeDomainToKey(domain));
|
||||
|
||||
if (typeof config !== "object") {
|
||||
console.warn("No config for this domain, falling back to default");
|
||||
config = await get(edgeDomainToKey("default"));
|
||||
}
|
||||
|
||||
if (typeof config !== "object") throw Error("Error fetching config");
|
||||
|
||||
return config as DomainConfig;
|
||||
}
|
||||
55
www/app/lib/features.ts
Normal file
55
www/app/lib/features.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const FEATURES = [
|
||||
"requireLogin",
|
||||
"privacy",
|
||||
"browse",
|
||||
"sendToZulip",
|
||||
"rooms",
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof FEATURES)[number];
|
||||
|
||||
export type Features = Readonly<Record<FeatureName, boolean>>;
|
||||
|
||||
export const DEFAULT_FEATURES: Features = {
|
||||
requireLogin: false,
|
||||
privacy: true,
|
||||
browse: false,
|
||||
sendToZulip: false,
|
||||
rooms: false,
|
||||
} as const;
|
||||
|
||||
function parseBooleanEnv(
|
||||
value: string | undefined,
|
||||
defaultValue: boolean = false,
|
||||
): boolean {
|
||||
if (!value) return defaultValue;
|
||||
return value.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
|
||||
const features: Features = {
|
||||
requireLogin: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
|
||||
DEFAULT_FEATURES.requireLogin,
|
||||
),
|
||||
privacy: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
|
||||
DEFAULT_FEATURES.privacy,
|
||||
),
|
||||
browse: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
|
||||
DEFAULT_FEATURES.browse,
|
||||
),
|
||||
sendToZulip: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
|
||||
DEFAULT_FEATURES.sendToZulip,
|
||||
),
|
||||
rooms: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
|
||||
DEFAULT_FEATURES.rooms,
|
||||
),
|
||||
};
|
||||
|
||||
export const featureEnabled = (featureName: FeatureName): boolean => {
|
||||
return features[featureName];
|
||||
};
|
||||
5
www/app/lib/transcript.ts
Normal file
5
www/app/lib/transcript.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { components } from "../reflector-api";
|
||||
|
||||
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
|
||||
|
||||
export type TranscriptStatus = ApiTranscriptStatus;
|
||||
@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
|
||||
// no other checks for now
|
||||
return r as CustomSession;
|
||||
};
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
|
||||
|
||||
export const assertExistsAndNonEmptyString = (
|
||||
value: string | null | undefined,
|
||||
err?: string,
|
||||
): NonEmptyString =>
|
||||
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
|
||||
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import system from "./styles/theme";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
|
||||
import { AuthProvider } from "./lib/AuthProvider";
|
||||
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
||||
|
||||
const WherebyProvider = dynamic(
|
||||
() =>
|
||||
import("@whereby.com/browser-sdk/react").then((mod) => ({
|
||||
default: mod.WherebyProvider,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
|
||||
13
www/app/reflector-api.d.ts
vendored
13
www/app/reflector-api.d.ts
vendored
@@ -926,8 +926,17 @@ export interface components {
|
||||
source_kind: components["schemas"]["SourceKind"];
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status:
|
||||
| "idle"
|
||||
| "uploaded"
|
||||
| "recording"
|
||||
| "processing"
|
||||
| "error"
|
||||
| "ended";
|
||||
/** Rank */
|
||||
rank: number;
|
||||
/**
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export const localConfig = {
|
||||
features: {
|
||||
requireLogin: true,
|
||||
privacy: true,
|
||||
browse: true,
|
||||
sendToZulip: true,
|
||||
rooms: true,
|
||||
},
|
||||
api_url: "http://127.0.0.1:1250",
|
||||
websocket_url: "ws://127.0.0.1:1250",
|
||||
auth_callback_url: "http://localhost:3000/auth-callback",
|
||||
zulip_streams: "", // Find the value on zulip
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { withAuth } from "next-auth/middleware";
|
||||
import { getConfig } from "./app/lib/edgeConfig";
|
||||
import { featureEnabled } from "./app/lib/features";
|
||||
import { NextResponse } from "next/server";
|
||||
import { PROTECTED_PAGES } from "./app/lib/auth";
|
||||
|
||||
@@ -19,13 +19,12 @@ export const config = {
|
||||
|
||||
export default withAuth(
|
||||
async function middleware(request) {
|
||||
const config = await getConfig();
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// feature-flags protected paths
|
||||
if (
|
||||
(!config.features.browse && pathname.startsWith("/browse")) ||
|
||||
(!config.features.rooms && pathname.startsWith("/rooms"))
|
||||
(!featureEnabled("browse") && pathname.startsWith("/browse")) ||
|
||||
(!featureEnabled("rooms") && pathname.startsWith("/rooms"))
|
||||
) {
|
||||
return NextResponse.redirect(request.nextUrl.origin);
|
||||
}
|
||||
@@ -33,10 +32,8 @@ export default withAuth(
|
||||
{
|
||||
callbacks: {
|
||||
async authorized({ req, token }) {
|
||||
const config = await getConfig();
|
||||
|
||||
if (
|
||||
config.features.requireLogin &&
|
||||
featureEnabled("requireLogin") &&
|
||||
PROTECTED_PAGES.test(req.nextUrl.pathname)
|
||||
) {
|
||||
return !!token;
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@sentry/nextjs": "^7.77.0",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@vercel/edge-config": "^0.4.1",
|
||||
"@whereby.com/browser-sdk": "^3.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"axios": "^1.8.2",
|
||||
@@ -63,8 +62,7 @@
|
||||
"jest": "^30.1.3",
|
||||
"openapi-typescript": "^7.9.1",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.4.1",
|
||||
"vercel": "^37.3.0"
|
||||
"ts-jest": "^29.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
||||
}
|
||||
|
||||
2562
www/pnpm-lock.yaml
generated
2562
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user