Merge main into jisti-integration branch

- Resolved conflicts in server/reflector/views/rooms.py to keep platform-agnostic approach
- Resolved conflicts in www/app/[roomName]/page.tsx to keep VideoPlatformEmbed approach
- Accepted main's version of generated API files (schemas.gen.ts, services.gen.ts, types.gen.ts)
- Removed config-template.ts as per main branch changes
This commit is contained in:
2025-09-15 12:53:49 -06:00
127 changed files with 11011 additions and 8821 deletions

View File

@@ -0,0 +1,30 @@
"use client";
import { Flex, Spinner } from "@chakra-ui/react";
import { useAuth } from "../lib/AuthProvider";
import { useLoginRequiredPages } from "../lib/useLoginRequiredPages";
export default function AuthWrapper({
children,
}: {
children: React.ReactNode;
}) {
const auth = useAuth();
const redirectPath = useLoginRequiredPages();
const redirectHappens = !!redirectPath;
if (auth.status === "loading" || redirectHappens) {
return (
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="calc(100vh - 80px)" // Account for header height
>
<Spinner size="xl" color="blue.500" />
</Flex>
);
}
return <>{children}</>;
}

View File

@@ -1,7 +1,10 @@
import React from "react";
import { Box, Stack, Link, Heading } from "@chakra-ui/react";
import NextLink from "next/link";
import { Room, SourceKind } from "../../../api";
import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
type SourceKind = components["schemas"]["SourceKind"];
interface FilterSidebarProps {
rooms: Room[];
@@ -72,7 +75,7 @@ export default function FilterSidebar({
key={room.id}
as={NextLink}
href="#"
onClick={() => onFilterChange("room", room.id)}
onClick={() => onFilterChange("room" as SourceKind, room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
? "blue.500"

View File

@@ -18,7 +18,10 @@ import {
highlightMatches,
generateTextFragment,
} from "../../../lib/textHighlight";
import { SearchResult } from "../../../api";
import type { components } from "../../../reflector-api";
type SearchResult = components["schemas"]["SearchResult"];
type SourceKind = components["schemas"]["SourceKind"];
interface TranscriptCardsProps {
results: SearchResult[];
@@ -120,7 +123,7 @@ function TranscriptCard({
: "N/A";
const formattedDate = formatLocalDate(result.created_at);
const source =
result.source_kind === "room"
result.source_kind === ("room" as SourceKind)
? result.room_name || result.room_id
: result.source_kind;

View File

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

View File

@@ -19,37 +19,33 @@ import {
parseAsStringLiteral,
} from "nuqs";
import { LuX } from "react-icons/lu";
import { useSearchTranscripts } from "../transcripts/useSearchTranscripts";
import useSessionUser from "../../lib/useSessionUser";
import { Room, SourceKind, SearchResult, $SourceKind } from "../../api";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import type { components } from "../../reflector-api";
type Room = components["schemas"]["Room"];
type SourceKind = components["schemas"]["SourceKind"];
type SearchResult = components["schemas"]["SearchResult"];
import {
useRoomsList,
useTranscriptsSearch,
useTranscriptDelete,
useTranscriptProcess,
} from "../../lib/apiHooks";
import FilterSidebar from "./_components/FilterSidebar";
import Pagination, {
FIRST_PAGE,
PaginationPage,
parsePaginationPage,
totalPages as getTotalPages,
paginationPageTo0Based,
} from "./_components/Pagination";
import TranscriptCards from "./_components/TranscriptCards";
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
import { formatLocalDate } from "../../lib/time";
import { RECORD_A_MEETING_URL } from "../../api/urls";
import { useUserName } from "../../lib/useUserName";
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
const usePrefetchRooms = (setRooms: (rooms: Room[]) => void): void => {
const { setError } = useError();
const api = useApi();
useEffect(() => {
if (!api) return;
api
.v1RoomsList({ page: 1 })
.then((rooms) => setRooms(rooms.items))
.catch((err) => setError(err, "There was an error fetching the rooms"));
}, [api, setError]);
};
const SearchForm: React.FC<{
setPage: (page: PaginationPage) => void;
sourceKind: SourceKind | null;
@@ -69,7 +65,6 @@ const SearchForm: React.FC<{
searchQuery,
setSearchQuery,
}) => {
// to keep the search input controllable + more fine grained control (urlSearchQuery is updated on submits)
const [searchInputValue, setSearchInputValue] = useState(searchQuery || "");
const handleSearchQuerySubmit = async (d: FormData) => {
await setSearchQuery((d.get(SEARCH_FORM_QUERY_INPUT_NAME) as string) || "");
@@ -163,7 +158,6 @@ const UnderSearchFormFilterIndicators: React.FC<{
p="1px"
onClick={() => {
setSourceKind(null);
// TODO questionable
setRoomId(null);
}}
_hover={{ bg: "blue.200" }}
@@ -209,7 +203,11 @@ export default function TranscriptBrowser() {
const [urlSourceKind, setUrlSourceKind] = useQueryState(
"source",
parseAsStringLiteral($SourceKind.enum).withOptions({
parseAsStringLiteral([
"room",
"live",
"file",
] as const satisfies SourceKind[]).withOptions({
shallow: false,
}),
);
@@ -229,46 +227,40 @@ export default function TranscriptBrowser() {
useEffect(() => {
const maybePage = parsePaginationPage(urlPage);
if ("error" in maybePage) {
setPage(FIRST_PAGE).then(() => {
/*may be called n times we dont care*/
});
setPage(FIRST_PAGE).then(() => {});
return;
}
_setSafePage(maybePage.value);
}, [urlPage]);
const [rooms, setRooms] = useState<Room[]>([]);
const pageSize = 20;
const {
results,
totalCount: totalResults,
isLoading,
reload,
} = useSearchTranscripts(
urlSearchQuery,
{
roomIds: urlRoomId ? [urlRoomId] : null,
sourceKind: urlSourceKind,
},
{
pageSize,
page,
},
);
data: searchData,
isLoading: searchLoading,
refetch: reloadSearch,
} = useTranscriptsSearch(urlSearchQuery, {
limit: pageSize,
offset: paginationPageTo0Based(page) * pageSize,
room_id: urlRoomId || undefined,
source_kind: urlSourceKind || undefined,
});
const results = searchData?.results || [];
const totalResults = searchData?.total || 0;
// Fetch rooms
const { data: roomsData } = useRoomsList(1);
const rooms = roomsData?.items || [];
const totalPages = getTotalPages(totalResults, pageSize);
const userName = useSessionUser().name;
const userName = useUserName();
const [deletionLoading, setDeletionLoading] = useState(false);
const api = useApi();
const { setError } = useError();
const cancelRef = React.useRef(null);
const [transcriptToDeleteId, setTranscriptToDeleteId] =
React.useState<string>();
usePrefetchRooms(setRooms);
const handleFilterTranscripts = (
sourceKind: SourceKind | null,
roomId: string,
@@ -280,44 +272,37 @@ export default function TranscriptBrowser() {
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const deleteTranscript = useTranscriptDelete();
const processTranscript = useTranscriptProcess();
const confirmDeleteTranscript = (transcriptId: string) => {
if (!api || deletionLoading) return;
if (deletionLoading) return;
setDeletionLoading(true);
api
.v1TranscriptDelete({ transcriptId })
.then(() => {
setDeletionLoading(false);
onCloseDeletion();
reload();
})
.catch((err) => {
setDeletionLoading(false);
setError(err, "There was an error deleting the transcript");
});
deleteTranscript.mutate(
{
params: {
path: { transcript_id: transcriptId },
},
},
{
onSuccess: () => {
setDeletionLoading(false);
onCloseDeletion();
reloadSearch();
},
onError: () => {
setDeletionLoading(false);
},
},
);
};
const handleProcessTranscript = (transcriptId: string) => {
if (!api) {
console.error("API not available on handleProcessTranscript");
return;
}
api
.v1TranscriptProcess({ transcriptId })
.then((result) => {
const status =
result && typeof result === "object" && "status" in result
? (result as { status: string }).status
: undefined;
if (status === "already running") {
setError(
new Error("Processing is already running, please wait"),
"Processing is already running, please wait",
);
}
})
.catch((err) => {
setError(err, "There was an error processing the transcript");
});
processTranscript.mutate({
params: {
path: { transcript_id: transcriptId },
},
});
};
const transcriptToDelete = results?.find(
@@ -332,7 +317,7 @@ export default function TranscriptBrowser() {
? transcriptToDelete.room_name || transcriptToDelete.room_id
: transcriptToDelete?.source_kind;
if (isLoading && results.length === 0) {
if (searchLoading && results.length === 0) {
return (
<Flex
flexDir="column"
@@ -360,7 +345,7 @@ export default function TranscriptBrowser() {
>
<Heading size="lg">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{(isLoading || deletionLoading) && <Spinner size="sm" />}
{(searchLoading || deletionLoading) && <Spinner size="sm" />}
</Heading>
</Flex>
@@ -403,12 +388,12 @@ export default function TranscriptBrowser() {
<TranscriptCards
results={results}
query={urlSearchQuery}
isLoading={isLoading}
isLoading={searchLoading}
onDelete={setTranscriptToDeleteId}
onReprocess={handleProcessTranscript}
/>
{!isLoading && results.length === 0 && (
{!searchLoading && results.length === 0 && (
<EmptyResult searchQuery={urlSearchQuery} />
)}
</Flex>

View File

@@ -1,10 +1,9 @@
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 About from "../(aboutAndPrivacy)/about";
import Privacy from "../(aboutAndPrivacy)/privacy";
import UserInfo from "../(auth)/userInfo";
import AuthWrapper from "./AuthWrapper";
import { RECORD_A_MEETING_URL } from "../api/urls";
export default async function AppLayout({
@@ -12,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"
@@ -59,7 +56,7 @@ export default async function AppLayout({
>
Create
</Link>
{browse ? (
{featureEnabled("browse") ? (
<>
&nbsp;·&nbsp;
<Link href="/browse" as={NextLink} className="font-light px-2">
@@ -69,7 +66,7 @@ export default async function AppLayout({
) : (
<></>
)}
{rooms ? (
{featureEnabled("rooms") ? (
<>
&nbsp;·&nbsp;
<Link href="/rooms" as={NextLink} className="font-light px-2">
@@ -79,7 +76,7 @@ export default async function AppLayout({
) : (
<></>
)}
{requireLogin ? (
{featureEnabled("requireLogin") ? (
<>
&nbsp;·&nbsp;
<UserInfo />
@@ -90,7 +87,7 @@ export default async function AppLayout({
</div>
</Flex>
{children}
<AuthWrapper>{children}</AuthWrapper>
</Container>
);
}

View File

@@ -13,7 +13,9 @@ import {
Badge,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { RoomDetails } from "../../../api";
import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
import { RoomActionsMenu } from "./RoomActionsMenu";
import {
getPlatformDisplayName,
@@ -21,7 +23,7 @@ import {
} from "../../../lib/videoPlatforms";
interface RoomCardsProps {
rooms: RoomDetails[];
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;

View File

@@ -1,11 +1,13 @@
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
import { RoomDetails } from "../../../api";
import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
interface RoomListProps {
title: string;
rooms: RoomDetails[];
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;

View File

@@ -10,7 +10,9 @@ import {
Badge,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { RoomDetails } from "../../../api";
import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
import { RoomActionsMenu } from "./RoomActionsMenu";
import {
getPlatformDisplayName,
@@ -18,7 +20,7 @@ import {
} from "../../../lib/videoPlatforms";
interface RoomTableProps {
rooms: RoomDetails[];
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;

View File

@@ -15,13 +15,24 @@ import {
createListCollection,
useDisclosure,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { ApiError, RoomDetails } from "../../api";
import type { components } from "../../reflector-api";
import {
useRoomCreate,
useRoomUpdate,
useRoomDelete,
useZulipStreams,
useZulipTopics,
useRoomGet,
useRoomTestWebhook,
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
import { assertExists } from "../../lib/utils";
type Room = components["schemas"]["Room"];
interface SelectOption {
label: string;
@@ -76,66 +87,77 @@ export default function RoomsList() {
const recordingTypeCollection = createListCollection({
items: recordingTypeOptions,
});
const [room, setRoom] = useState(roomInitialState);
const [roomInput, setRoomInput] = useState<null | typeof roomInitialState>(
null,
);
const [isEditing, setIsEditing] = useState(false);
const [editRoomId, setEditRoomId] = useState("");
const api = useApi();
// TODO seems to be no setPage calls
const [page, setPage] = useState<number>(1);
const { loading, response, refetch } = useRoomList(PaginationPage(page));
const [streams, setStreams] = useState<Stream[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const [editRoomId, setEditRoomId] = useState<string | null>(null);
const {
loading,
response,
refetch,
error: roomListError,
} = useRoomList(PaginationPage(1));
const [nameError, setNameError] = useState("");
const [linkCopied, setLinkCopied] = useState("");
const [selectedStreamId, setSelectedStreamId] = useState<number | null>(null);
const [testingWebhook, setTestingWebhook] = useState(false);
const [webhookTestResult, setWebhookTestResult] = useState<string | null>(
null,
);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
interface Stream {
stream_id: number;
name: string;
}
interface Topic {
name: string;
}
const createRoomMutation = useRoomCreate();
const updateRoomMutation = useRoomUpdate();
const deleteRoomMutation = useRoomDelete();
const { data: streams = [] } = useZulipStreams();
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const {
data: detailedEditedRoom,
isLoading: isDetailedEditedRoomLoading,
error: detailedEditedRoomError,
} = useRoomGet(editRoomId);
const error = roomListError || detailedEditedRoomError;
// room being edited, as fetched from the server
const editedRoom: typeof roomInitialState | null = useMemo(
() =>
detailedEditedRoom
? {
name: detailedEditedRoom.name,
zulipAutoPost: detailedEditedRoom.zulip_auto_post,
zulipStream: detailedEditedRoom.zulip_stream,
zulipTopic: detailedEditedRoom.zulip_topic,
isLocked: detailedEditedRoom.is_locked,
roomMode: detailedEditedRoom.room_mode,
recordingType: detailedEditedRoom.recording_type,
recordingTrigger: detailedEditedRoom.recording_trigger,
isShared: detailedEditedRoom.is_shared,
webhookUrl: detailedEditedRoom.webhook_url || "",
webhookSecret: detailedEditedRoom.webhook_secret || "",
}
: null,
[detailedEditedRoom],
);
// a room input value or a last api room state
const room = roomInput || editedRoom || roomInitialState;
const roomTestWebhookMutation = useRoomTestWebhook();
// Update selected stream ID when zulip stream changes
useEffect(() => {
const fetchZulipStreams = async () => {
if (!api) return;
try {
const response = await api.v1ZulipGetStreams();
setStreams(response);
} catch (error) {
console.error("Error fetching Zulip streams:", error);
if (room.zulipStream && streams.length > 0) {
const selectedStream = streams.find((s) => s.name === room.zulipStream);
if (selectedStream !== undefined) {
setSelectedStreamId(selectedStream.stream_id);
}
};
if (room.zulipAutoPost) {
fetchZulipStreams();
} else {
setSelectedStreamId(null);
}
}, [room.zulipAutoPost, !api]);
useEffect(() => {
const fetchZulipTopics = async () => {
if (!api || !room.zulipStream) return;
try {
const selectedStream = streams.find((s) => s.name === room.zulipStream);
if (selectedStream) {
const response = await api.v1ZulipGetTopics({
streamId: selectedStream.stream_id,
});
setTopics(response);
}
} catch (error) {
console.error("Error fetching Zulip topics:", error);
}
};
fetchZulipTopics();
}, [room.zulipStream, streams, api]);
}, [room.zulipStream, streams]);
const streamOptions: SelectOption[] = streams.map((stream) => {
return { label: stream.name, value: stream.name };
@@ -167,35 +189,42 @@ export default function RoomsList() {
const handleCloseDialog = () => {
setShowWebhookSecret(false);
setWebhookTestResult(null);
setEditRoomId(null);
onClose();
};
const handleTestWebhook = async () => {
if (!room.webhookUrl || !editRoomId) {
if (!room.webhookUrl) {
setWebhookTestResult("Please enter a webhook URL first");
return;
}
if (!editRoomId) {
console.error("No room ID to test webhook");
return;
}
setTestingWebhook(true);
setWebhookTestResult(null);
try {
const response = await api?.v1RoomsTestWebhook({
roomId: editRoomId,
const response = await roomTestWebhookMutation.mutateAsync({
params: {
path: {
room_id: editRoomId,
},
},
});
if (response?.success) {
if (response.success) {
setWebhookTestResult(
`✅ Webhook test successful! Status: ${response.status_code}`,
);
} else {
let errorMsg = `❌ Webhook test failed`;
if (response?.status_code) {
errorMsg += ` (Status: ${response.status_code})`;
}
if (response?.error) {
errorMsg += ` (Status: ${response.status_code})`;
if (response.error) {
errorMsg += `: ${response.error}`;
} else if (response?.response_preview) {
} else if (response.response_preview) {
// Try to parse and extract meaningful error from response
// Specific to N8N at the moment, as there is no specification for that
// We could just display as is, but decided here to dig a little bit more.
@@ -249,27 +278,29 @@ export default function RoomsList() {
};
if (isEditing) {
await api?.v1RoomsUpdate({
roomId: editRoomId,
requestBody: roomData,
await updateRoomMutation.mutateAsync({
params: {
path: { room_id: assertExists(editRoomId) },
},
body: roomData,
});
} else {
await api?.v1RoomsCreate({
requestBody: roomData,
await createRoomMutation.mutateAsync({
body: roomData,
});
}
setRoom(roomInitialState);
setRoomInput(null);
setIsEditing(false);
setEditRoomId("");
setNameError("");
refetch();
onClose();
handleCloseDialog();
} catch (err) {
} catch (err: any) {
if (
err instanceof ApiError &&
err.status === 400 &&
(err.body as any).detail == "Room name is not unique"
err?.status === 400 &&
err?.body?.detail == "Room name is not unique"
) {
setNameError(
"This room name is already taken. Please choose a different name.",
@@ -280,46 +311,11 @@ export default function RoomsList() {
}
};
const handleEditRoom = async (roomId, roomData) => {
const handleEditRoom = async (roomId: string, roomData) => {
// Reset states
setShowWebhookSecret(false);
setWebhookTestResult(null);
// Fetch full room details to get webhook fields
try {
const detailedRoom = await api?.v1RoomsGet({ roomId });
if (detailedRoom) {
setRoom({
name: detailedRoom.name,
zulipAutoPost: detailedRoom.zulip_auto_post,
zulipStream: detailedRoom.zulip_stream,
zulipTopic: detailedRoom.zulip_topic,
isLocked: detailedRoom.is_locked,
roomMode: detailedRoom.room_mode,
recordingType: detailedRoom.recording_type,
recordingTrigger: detailedRoom.recording_trigger,
isShared: detailedRoom.is_shared,
webhookUrl: detailedRoom.webhook_url || "",
webhookSecret: detailedRoom.webhook_secret || "",
});
}
} catch (error) {
console.error("Failed to fetch room details, using list data:", error);
// Fallback to using the data from the list
setRoom({
name: roomData.name,
zulipAutoPost: roomData.zulip_auto_post,
zulipStream: roomData.zulip_stream,
zulipTopic: roomData.zulip_topic,
isLocked: roomData.is_locked,
roomMode: roomData.room_mode,
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
webhookUrl: roomData.webhook_url || "",
webhookSecret: roomData.webhook_secret || "",
});
}
setEditRoomId(roomId);
setIsEditing(true);
setNameError("");
@@ -328,8 +324,10 @@ export default function RoomsList() {
const handleDeleteRoom = async (roomId: string) => {
try {
await api?.v1RoomsDelete({
roomId,
await deleteRoomMutation.mutateAsync({
params: {
path: { room_id: roomId },
},
});
refetch();
} catch (err) {
@@ -346,15 +344,15 @@ export default function RoomsList() {
.toLowerCase();
setNameError("");
}
setRoom({
setRoomInput({
...room,
[name]: type === "checkbox" ? checked : value,
});
};
const myRooms: RoomDetails[] =
const myRooms: Room[] =
response?.items.filter((roomData) => !roomData.is_shared) || [];
const sharedRooms: RoomDetails[] =
const sharedRooms: Room[] =
response?.items.filter((roomData) => roomData.is_shared) || [];
if (loading && !response)
@@ -369,6 +367,9 @@ export default function RoomsList() {
</Flex>
);
if (roomListError)
return <div>{`${roomListError.name}: ${roomListError.message}`}</div>;
return (
<Flex
flexDir="column"
@@ -387,7 +388,7 @@ export default function RoomsList() {
colorPalette="primary"
onClick={() => {
setIsEditing(false);
setRoom(roomInitialState);
setRoomInput(null);
setNameError("");
setShowWebhookSecret(false);
setWebhookTestResult(null);
@@ -456,7 +457,7 @@ export default function RoomsList() {
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoom({ ...room, roomMode: e.value[0] })
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
@@ -486,7 +487,7 @@ export default function RoomsList() {
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoom({
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
@@ -521,7 +522,7 @@ export default function RoomsList() {
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoom({ ...room, recordingTrigger: e.value[0] })
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
@@ -576,7 +577,7 @@ export default function RoomsList() {
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoom({
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
@@ -611,7 +612,7 @@ export default function RoomsList() {
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoom({ ...room, zulipTopic: e.value[0] })
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}

View File

@@ -1,48 +1,27 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { Page_RoomDetails_ } from "../../api";
import { useRoomsList } from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
type Page_Room_ = components["schemas"]["Page_RoomDetails_"];
import { PaginationPage } from "../browse/_components/Pagination";
type RoomList = {
response: Page_RoomDetails_ | null;
response: Page_Room_ | null;
loading: boolean;
error: Error | null;
refetch: () => void;
};
//always protected
// Wrapper to maintain backward compatibility
const useRoomList = (page: PaginationPage): RoomList => {
const [response, setResponse] = useState<Page_RoomDetails_ | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [refetchCount, setRefetchCount] = useState(0);
const refetch = () => {
setLoading(true);
setRefetchCount(refetchCount + 1);
const { data, isLoading, error, refetch } = useRoomsList(page);
return {
response: data || null,
loading: isLoading,
error: error
? new Error(error.detail ? JSON.stringify(error.detail) : undefined)
: null,
refetch,
};
useEffect(() => {
if (!api) return;
setLoading(true);
api
.v1RoomsList({ page })
.then((response) => {
setResponse(response);
setLoading(false);
})
.catch((err) => {
setResponse(null);
setLoading(false);
setError(err);
setErrorState(err);
});
}, [!api, page, refetchCount]);
return { response, loading, error, refetch };
};
export default useRoomList;

View File

@@ -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;
};

View File

@@ -1,30 +1,35 @@
"use client";
import { useState } from "react";
import { useState, use } from "react";
import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer";
import useParticipants from "../../useParticipants";
import useTopicWithWords from "../../useTopicWithWords";
import ParticipantList from "./participantList";
import { GetTranscriptTopic } from "../../../../api";
import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { SelectedText, selectedTextIsTimeSlice } from "./types";
import useApi from "../../../../lib/useApi";
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";
export type TranscriptCorrect = {
params: {
params: Promise<{
transcriptId: string;
};
}>;
};
export default function TranscriptCorrect({
params: { transcriptId },
}: TranscriptCorrect) {
const api = useApi();
const transcript = useTranscript(transcriptId);
export default function TranscriptCorrect(props: TranscriptCorrect) {
const params = use(props.params);
const { transcriptId } = params;
const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState<SelectedText>();
@@ -34,16 +39,21 @@ export default function TranscriptCorrect({
const { setError } = useError();
const router = useRouter();
const markAsDone = () => {
if (transcript.response && !transcript.response.reviewed) {
api
?.v1TranscriptUpdate({ transcriptId, requestBody: { reviewed: true } })
.then(() => {
router.push(`/transcripts/${transcriptId}`);
})
.catch((e) => {
setError(e, "Error marking as done");
const markAsDone = async () => {
if (transcript.data && !transcript.data.reviewed) {
try {
await updateTranscriptMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: { reviewed: true },
});
router.push(`/transcripts/${transcriptId}`);
} catch (e) {
setError(e as Error, "Error marking as done");
}
}
};
@@ -108,7 +118,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"

View File

@@ -1,8 +1,15 @@
import { faArrowTurnDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { Participant } from "../../../../api";
import useApi from "../../../../lib/useApi";
import type { components } from "../../../../reflector-api";
type Participant = components["schemas"]["Participant"];
import {
useTranscriptSpeakerAssign,
useTranscriptSpeakerMerge,
useTranscriptParticipantUpdate,
useTranscriptParticipantCreate,
useTranscriptParticipantDelete,
} from "../../../../lib/apiHooks";
import { UseParticipants } from "../../useParticipants";
import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./types";
import { useError } from "../../../../(errors)/errorContext";
@@ -30,9 +37,19 @@ const ParticipantList = ({
topicWithWords,
stateSelectedText,
}: ParticipantList) => {
const api = useApi();
const { setError } = useError();
const [loading, setLoading] = useState(false);
const speakerAssignMutation = useTranscriptSpeakerAssign();
const speakerMergeMutation = useTranscriptSpeakerMerge();
const participantUpdateMutation = useTranscriptParticipantUpdate();
const participantCreateMutation = useTranscriptParticipantCreate();
const participantDeleteMutation = useTranscriptParticipantDelete();
const loading =
speakerAssignMutation.isPending ||
speakerMergeMutation.isPending ||
participantUpdateMutation.isPending ||
participantCreateMutation.isPending ||
participantDeleteMutation.isPending;
const [participantInput, setParticipantInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [selectedText, setSelectedText] = stateSelectedText;
@@ -103,7 +120,6 @@ const ParticipantList = ({
const onSuccess = () => {
topicWithWords.refetch();
participants.refetch();
setLoading(false);
setAction(null);
setSelectedText(undefined);
setSelectedParticipant(undefined);
@@ -120,11 +136,14 @@ const ParticipantList = ({
if (loading || participants.loading || topicWithWords.loading) return;
if (!selectedTextIsTimeSlice(selectedText)) return;
setLoading(true);
try {
await api?.v1TranscriptAssignSpeaker({
transcriptId,
requestBody: {
await speakerAssignMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
participant: participant.id,
timestamp_from: selectedText.start,
timestamp_to: selectedText.end,
@@ -132,8 +151,7 @@ const ParticipantList = ({
});
onSuccess();
} catch (error) {
setError(error, "There was an error assigning");
setLoading(false);
setError(error as Error, "There was an error assigning");
throw error;
}
};
@@ -141,32 +159,38 @@ const ParticipantList = ({
const mergeSpeaker =
(speakerFrom, participantTo: Participant) => async () => {
if (loading || participants.loading || topicWithWords.loading) return;
setLoading(true);
if (participantTo.speaker) {
try {
await api?.v1TranscriptMergeSpeaker({
transcriptId,
requestBody: {
await speakerMergeMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
speaker_from: speakerFrom,
speaker_to: participantTo.speaker,
},
});
onSuccess();
} catch (error) {
setError(error, "There was an error merging");
setLoading(false);
setError(error as Error, "There was an error merging");
}
} else {
try {
await api?.v1TranscriptUpdateParticipant({
transcriptId,
participantId: participantTo.id,
requestBody: { speaker: speakerFrom },
await participantUpdateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
participant_id: participantTo.id,
},
},
body: { speaker: speakerFrom },
});
onSuccess();
} catch (error) {
setError(error, "There was an error merging (update)");
setLoading(false);
setError(error as Error, "There was an error merging (update)");
}
}
};
@@ -186,105 +210,106 @@ const ParticipantList = ({
(p) => p.speaker == selectedText,
);
if (participant && participant.name !== participantInput) {
setLoading(true);
api
?.v1TranscriptUpdateParticipant({
transcriptId,
participantId: participant.id,
requestBody: {
try {
await participantUpdateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
participant_id: participant.id,
},
},
body: {
name: participantInput,
},
})
.then(() => {
participants.refetch();
setLoading(false);
setAction(null);
})
.catch((e) => {
setError(e, "There was an error renaming");
setLoading(false);
});
participants.refetch();
setAction(null);
} catch (e) {
setError(e as Error, "There was an error renaming");
}
}
} else if (
action == "Create to rename" &&
selectedTextIsSpeaker(selectedText)
) {
setLoading(true);
api
?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
try {
await participantCreateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
name: participantInput,
speaker: selectedText,
},
})
.then(() => {
participants.refetch();
setParticipantInput("");
setOneMatch(undefined);
setLoading(false);
})
.catch((e) => {
setError(e, "There was an error creating");
setLoading(false);
});
participants.refetch();
setParticipantInput("");
setOneMatch(undefined);
} catch (e) {
setError(e as Error, "There was an error creating");
}
} else if (
action == "Create and assign" &&
selectedTextIsTimeSlice(selectedText)
) {
setLoading(true);
try {
const participant = await api?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
const participant = await participantCreateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
name: participantInput,
},
});
setLoading(false);
assignTo(participant)().catch(() => {
// error and loading are handled by assignTo catch
participants.refetch();
});
} catch (error) {
setError(e, "There was an error creating");
setLoading(false);
setError(error as Error, "There was an error creating");
}
} else if (action == "Create") {
setLoading(true);
api
?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
try {
await participantCreateMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
name: participantInput,
},
})
.then(() => {
participants.refetch();
setParticipantInput("");
setLoading(false);
inputRef.current?.focus();
})
.catch((e) => {
setError(e, "There was an error creating");
setLoading(false);
});
participants.refetch();
setParticipantInput("");
inputRef.current?.focus();
} catch (e) {
setError(e as Error, "There was an error creating");
}
}
};
const deleteParticipant = (participantId) => (e) => {
const deleteParticipant = (participantId) => async (e) => {
e.stopPropagation();
if (loading || participants.loading || topicWithWords.loading) return;
setLoading(true);
api
?.v1TranscriptDeleteParticipant({ transcriptId, participantId })
.then(() => {
participants.refetch();
setLoading(false);
})
.catch((e) => {
setError(e, "There was an error deleting");
setLoading(false);
try {
await participantDeleteMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
participant_id: participantId,
},
},
});
participants.refetch();
} catch (e) {
setError(e as Error, "There was an error deleting");
}
};
const selectParticipant = (participant) => (e) => {

View File

@@ -1,6 +1,7 @@
import useTopics from "../../useTopics";
import { Dispatch, SetStateAction, useEffect } from "react";
import { GetTranscriptTopic } from "../../../../api";
import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import {
BoxProps,
Box,

View File

@@ -2,12 +2,10 @@ import { useEffect, useRef, useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import "../../../styles/markdown.css";
import {
GetTranscript,
GetTranscriptTopic,
UpdateTranscript,
} from "../../../api";
import useApi from "../../../lib/useApi";
import type { components } from "../../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { useTranscriptUpdate } from "../../../lib/apiHooks";
import {
Flex,
Heading,
@@ -33,9 +31,8 @@ export default function FinalSummary(props: FinalSummaryProps) {
const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState("");
const api = useApi();
const { setError } = useError();
const updateTranscriptMutation = useTranscriptUpdate();
useEffect(() => {
setEditedSummary(props.transcriptResponse?.long_summary || "");
@@ -47,12 +44,15 @@ export default function FinalSummary(props: FinalSummaryProps) {
const updateSummary = async (newSummary: string, transcriptId: string) => {
try {
const requestBody: UpdateTranscript = {
long_summary: newSummary,
};
const updatedTranscript = await api?.v1TranscriptUpdate({
transcriptId,
requestBody,
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: {
long_summary: newSummary,
},
});
if (props.onUpdate) {
props.onUpdate(newSummary);
@@ -60,7 +60,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
setError(err, "Failed to update long summary.");
setError(err as Error, "Failed to update long summary.");
}
};
@@ -114,7 +114,12 @@ export default function FinalSummary(props: FinalSummaryProps) {
<Button onClick={onDiscardClick} variant="ghost">
Cancel
</Button>
<Button onClick={onSaveClick}>Save</Button>
<Button
onClick={onSaveClick}
disabled={updateTranscriptMutation.isPending}
>
Save
</Button>
</Flex>
)}
{!isEditMode && (

View File

@@ -1,32 +1,38 @@
"use client";
import Modal from "../modal";
import useTranscript from "../useTranscript";
import useTopics from "../useTopics";
import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, use } from "react";
import FinalSummary from "./finalSummary";
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: {
params: Promise<{
transcriptId: string;
};
}>;
};
export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId;
const params = use(details.params);
const transcriptId = 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);
@@ -38,7 +44,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => {
if (waiting) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
const newUrl = "/transcripts/" + params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540
@@ -56,7 +62,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 +92,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
mediaDuration={transcript.data?.duration || null}
/>
) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md">
@@ -116,10 +122,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 +142,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>

View File

@@ -1,8 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, use } 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,26 +10,29 @@ 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: {
params: Promise<{
transcriptId: string;
};
}>;
};
const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId);
const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null);
const webSockets = useWebSockets(details.params.transcriptId);
const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true);
const mp3 = useMp3(params.transcriptId, true);
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,15 +43,15 @@ 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");
const newUrl = "/transcripts/" + details.params.transcriptId;
const newUrl = "/transcripts/" + 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();
@@ -74,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
<WaveformLoading />
) : (
// todo: only start recording animation when you get "recorded" status
<Recorder transcriptId={details.params.transcriptId} status={status} />
<Recorder transcriptId={params.transcriptId} status={status} />
)}
<VStack
align={"left"}
@@ -97,7 +99,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
topics={webSockets.topics}
useActiveTopic={useActiveTopic}
autoscroll={true}
transcriptId={details.params.transcriptId}
transcriptId={params.transcriptId}
status={status}
currentTranscriptText={webSockets.accumulatedText}
/>

View File

@@ -1,33 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useEffect, useState, use } from "react";
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: {
params: Promise<{
transcriptId: string;
};
}>;
};
const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscript(details.params.transcriptId);
const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId);
const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true);
const mp3 = useMp3(params.transcriptId, true);
const router = useRouter();
const [status, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle",
const [status_, setStatus] = useState(
webSockets.status?.value || transcript.data?.status || "idle",
);
// status is obviously done if we have transcript
const status =
!transcript.isLoading && transcript.data?.status === "ended"
? transcript.data?.status
: status_;
useEffect(() => {
if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
setTranscriptStarted(true);
@@ -35,16 +42,19 @@ const TranscriptUpload = (details: TranscriptUpload) => {
useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
const newStatus =
webSockets.status.value || transcript.response?.status || "idle";
transcript.data?.status === "ended"
? "ended"
: webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId;
const newUrl = "/transcripts/" + 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();
@@ -75,7 +85,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm
</Text>
<FileUploadButton transcriptId={details.params.transcriptId} />
<FileUploadButton transcriptId={params.transcriptId} />
</>
)}
{status && status == "uploaded" && (

View File

@@ -1,45 +1,33 @@
import { useEffect, useState } from "react";
import type { components } from "../../reflector-api";
import { useTranscriptCreate } from "../../lib/apiHooks";
import { useError } from "../../(errors)/errorContext";
import { CreateTranscript, GetTranscript } from "../../api";
import useApi from "../../lib/useApi";
type CreateTranscript = components["schemas"]["CreateTranscript"];
type GetTranscript = components["schemas"]["GetTranscript"];
type UseCreateTranscript = {
transcript: GetTranscript | null;
loading: boolean;
error: Error | null;
create: (transcriptCreationDetails: CreateTranscript) => void;
create: (transcriptCreationDetails: CreateTranscript) => Promise<void>;
};
const useCreateTranscript = (): UseCreateTranscript => {
const [transcript, setTranscript] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const createMutation = useTranscriptCreate();
const create = (transcriptCreationDetails: CreateTranscript) => {
if (loading || !api) return;
const create = async (transcriptCreationDetails: CreateTranscript) => {
if (createMutation.isPending) return;
setLoading(true);
api
.v1TranscriptsCreate({ requestBody: transcriptCreationDetails })
.then((transcript) => {
setTranscript(transcript);
setLoading(false);
})
.catch((err) => {
setError(
err,
"There was an issue creating a transcript, please try again.",
);
setErrorState(err);
setLoading(false);
});
await createMutation.mutateAsync({
body: transcriptCreationDetails,
});
};
return { transcript, loading, error, create };
return {
transcript: createMutation.data || null,
loading: createMutation.isPending,
error: createMutation.error as Error | null,
create,
};
};
export default useCreateTranscript;

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react";
import useApi from "../../lib/useApi";
import { useTranscriptUploadAudio } from "../../lib/apiHooks";
import { Button, Spinner } from "@chakra-ui/react";
import { useError } from "../../(errors)/errorContext";
type FileUploadButton = {
transcriptId: string;
@@ -8,13 +9,16 @@ type FileUploadButton = {
export default function FileUploadButton(props: FileUploadButton) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const api = useApi();
const uploadMutation = useTranscriptUploadAudio();
const { setError } = useError();
const [progress, setProgress] = useState(0);
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (file) {
@@ -24,37 +28,45 @@ export default function FileUploadButton(props: FileUploadButton) {
let start = 0;
let uploadedSize = 0;
api?.httpRequest.config.interceptors.request.use((request) => {
request.onUploadProgress = (progressEvent) => {
const currentProgress = Math.floor(
((uploadedSize + progressEvent.loaded) / file.size) * 100,
);
setProgress(currentProgress);
};
return request;
});
const uploadNextChunk = async () => {
if (chunkNumber == totalChunks) return;
if (chunkNumber == totalChunks) {
setProgress(0);
return;
}
const chunkSize = Math.min(maxChunkSize, file.size - start);
const end = start + chunkSize;
const chunk = file.slice(start, end);
await api?.v1TranscriptRecordUpload({
transcriptId: props.transcriptId,
formData: {
chunk,
},
chunkNumber,
totalChunks,
});
try {
const formData = new FormData();
formData.append("chunk", chunk);
uploadedSize += chunkSize;
chunkNumber++;
start = end;
await uploadMutation.mutateAsync({
params: {
path: {
transcript_id: props.transcriptId,
},
query: {
chunk_number: chunkNumber,
total_chunks: totalChunks,
},
},
body: formData as any,
});
uploadNextChunk();
uploadedSize += chunkSize;
const currentProgress = Math.floor((uploadedSize / file.size) * 100);
setProgress(currentProgress);
chunkNumber++;
start = end;
await uploadNextChunk();
} catch (error) {
setError(error as Error, "Failed to upload file");
setProgress(0);
}
};
uploadNextChunk();

View File

@@ -9,33 +9,25 @@ import { useRouter } from "next/navigation";
import useCreateTranscript from "../createTranscript";
import SelectSearch from "react-select-search";
import { supportedLanguages } from "../../../supportedLanguages";
import useSessionStatus from "../../../lib/useSessionStatus";
import { featureEnabled } from "../../../domainContext";
import { signIn } from "next-auth/react";
import {
Flex,
Box,
Spinner,
Heading,
Button,
Card,
Center,
Link,
CardBody,
Stack,
Text,
Icon,
Grid,
IconButton,
Spacer,
Menu,
Tooltip,
Input,
} from "@chakra-ui/react";
import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
const TranscriptCreate = () => {
const isClient = typeof window !== "undefined";
const router = useRouter();
const { isLoading, isAuthenticated } = useSessionStatus();
const auth = useAuth();
const isAuthenticated = auth.status === "authenticated";
const isAuthRefreshing = auth.status === "refreshing";
const isLoading = auth.status === "loading";
const requireLogin = featureEnabled("requireLogin");
const [name, setName] = useState<string>("");
@@ -54,20 +46,32 @@ const TranscriptCreate = () => {
const [loadingUpload, setLoadingUpload] = useState(false);
const getTargetLanguage = () => {
if (targetLanguage === "NOTRANSLATION") return;
if (targetLanguage === "NOTRANSLATION") return undefined;
return targetLanguage;
};
const send = () => {
if (loadingRecord || createTranscript.loading || permissionDenied) return;
setLoadingRecord(true);
createTranscript.create({ name, target_language: getTargetLanguage() });
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
target_language: targetLang || "en",
source_kind: "live",
});
};
const uploadFile = () => {
if (loadingUpload || createTranscript.loading || permissionDenied) return;
setLoadingUpload(true);
createTranscript.create({ name, target_language: getTargetLanguage() });
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
target_language: targetLang || "en",
source_kind: "file",
});
};
useEffect(() => {
@@ -132,8 +136,8 @@ const TranscriptCreate = () => {
<Center>
{isLoading ? (
<Spinner />
) : requireLogin && !isAuthenticated ? (
<Button onClick={() => signIn("authentik")}>Log in</Button>
) : requireLogin && !isAuthenticated && !isAuthRefreshing ? (
<Button onClick={() => auth.signIn("authentik")}>Log in</Button>
) : (
<Flex
rounded="xl"
@@ -170,7 +174,7 @@ const TranscriptCreate = () => {
placeholder="Choose your language"
/>
</Box>
{isClient && !loading ? (
{!loading ? (
permissionOk ? (
<Spacer />
) : permissionDenied ? (

View File

@@ -5,7 +5,9 @@ import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
import { formatTime, formatTimeMs } from "../../lib/time";
import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api";
import type { components } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"];
import { waveSurferStyles } from "../../styles/recorder";
import { Box, Flex, IconButton } from "@chakra-ui/react";
import { LuPause, LuPlay } from "react-icons/lu";
@@ -18,7 +20,7 @@ type PlayerProps = {
];
waveform: AudioWaveform;
media: HTMLMediaElement;
mediaDuration: number;
mediaDuration: number | null;
};
export default function Player(props: PlayerProps) {
@@ -50,7 +52,9 @@ export default function Player(props: PlayerProps) {
container: waveformRef.current,
peaks: [props.waveform.data],
height: "auto",
duration: Math.floor(props.mediaDuration / 1000),
duration: props.mediaDuration
? Math.floor(props.mediaDuration / 1000)
: undefined,
media: props.media,
...waveSurferStyles.playerSettings,

View File

@@ -6,16 +6,16 @@ import RecordPlugin from "../../lib/custom-plugins/record";
import { formatTime, formatTimeMs } from "../../lib/time";
import { waveSurferStyles } from "../../styles/recorder";
import { useError } from "../../(errors)/errorContext";
import FileUploadButton from "./fileUploadButton";
import useWebRTC from "./useWebRTC";
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) {

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { featureEnabled } from "../../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import { GetTranscript, GetTranscriptTopic, UpdateTranscript } from "../../api";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
import {
Box,
Flex,
@@ -15,12 +17,13 @@ import {
createListCollection,
} from "@chakra-ui/react";
import { LuShare2 } from "react-icons/lu";
import useApi from "../../lib/useApi";
import useSessionUser from "../../lib/useSessionUser";
import { CustomSession } from "../../lib/types";
import { useTranscriptUpdate } from "../../lib/apiHooks";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = {
finalSummaryRef: any;
@@ -50,12 +53,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
);
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const api = useApi();
const updateTranscriptMutation = useTranscriptUpdate();
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,
);
@@ -67,19 +67,27 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
share_mode: selectedValue as "public" | "semi-private" | "private",
};
const updatedTranscript = await api.v1TranscriptUpdate({
transcriptId: props.transcriptResponse.id,
requestBody,
});
setShareMode(
shareOptionsData.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptionsData[0],
);
setShareLoading(false);
try {
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
params: {
path: { transcript_id: props.transcriptResponse.id },
},
body: requestBody,
});
setShareMode(
shareOptionsData.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptionsData[0],
);
} catch (err) {
console.error("Failed to update share mode:", err);
} finally {
setShareLoading(false);
}
};
const userId = useSessionUser().id;
const auth = useAuth();
const userId = auth.status === "authenticated" ? auth.user?.id : null;
useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
@@ -124,7 +132,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
"This transcript is public. Everyone can access it."}
</Text>
{isOwner && api && (
{isOwner && (
<Select.Root
key={shareMode.value}
value={[shareMode.value || ""]}

View File

@@ -1,5 +1,7 @@
import { useState } from "react";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = {

View File

@@ -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;
};

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useMemo } from "react";
import { featureEnabled } from "../../domainContext";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import {
BoxProps,
Button,
@@ -12,12 +14,16 @@ import {
Checkbox,
Combobox,
Spinner,
Portal,
useFilter,
useListCollection,
createListCollection,
} from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb";
import useApi from "../../lib/useApi";
import {
useZulipStreams,
useZulipTopics,
useTranscriptPostToZulip,
} from "../../lib/apiHooks";
import { featureEnabled } from "../../lib/features";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
@@ -30,104 +36,77 @@ interface Stream {
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 [selectedStreamId, setSelectedStreamId] = useState<number | null>(null);
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({
initialItems: [] as { label: string; value: string }[],
filter: contains,
});
const { data: streams = [], isLoading: isLoadingStreams } = useZulipStreams();
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const postToZulipMutation = useTranscriptPostToZulip();
const {
collection: topicItemsCollection,
filter: topicItemsFilter,
set: topicItemsSet,
} = useListCollection({
initialItems: [] as { label: string; value: string }[],
filter: contains,
});
const streamItems = useMemo(() => {
return streams.map((stream: Stream) => ({
label: stream.name,
value: stream.name,
}));
}, [streams]);
const topicItems = useMemo(() => {
return topics.map(({ name }) => ({
label: name,
value: name,
}));
}, [topics]);
const streamCollection = useMemo(
() =>
createListCollection({
items: streamItems,
}),
[streamItems],
);
const topicCollection = useMemo(
() =>
createListCollection({
items: topicItems,
}),
[topicItems],
);
// Update selected stream ID when stream changes
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]);
if (stream && streams) {
const selectedStream = streams.find((s: Stream) => s.name === stream);
setSelectedStreamId(selectedStream ? selectedStream.stream_id : null);
} else {
setSelectedStreamId(null);
}
}, [stream, streams]);
const handleSendToZulip = async () => {
if (!api || !props.transcriptResponse) return;
if (!props.transcriptResponse) return;
if (stream && topic) {
try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcriptResponse.id,
stream,
topic,
includeTopics,
await postToZulipMutation.mutateAsync({
params: {
path: {
transcript_id: props.transcriptResponse.id,
},
query: {
stream,
topic,
include_topics: includeTopics,
},
},
});
setShowModal(false);
} catch (error) {
console.log(error);
console.error("Error posting to Zulip:", error);
}
}
};
@@ -155,7 +134,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{isLoading ? (
{isLoadingStreams ? (
<Flex justify="center" py={8}>
<Spinner />
</Flex>
@@ -178,15 +157,12 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}>
<Text>#</Text>
<Combobox.Root
collection={streamItemsCollection}
collection={streamCollection}
value={stream ? [stream] : []}
onValueChange={(e) => {
setTopic(undefined);
setStream(e.value[0]);
}}
onInputValueChange={(e) =>
streamItemsFilter(e.inputValue)
}
openOnClick={true}
positioning={{
strategy: "fixed",
@@ -203,7 +179,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No streams found</Combobox.Empty>
{streamItemsCollection.items.map((item) => (
{streamItems.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
</Combobox.Item>
@@ -219,12 +195,9 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}>
<Text visibility="hidden">#</Text>
<Combobox.Root
collection={topicItemsCollection}
collection={topicCollection}
value={topic ? [topic] : []}
onValueChange={(e) => setTopic(e.value[0])}
onInputValueChange={(e) =>
topicItemsFilter(e.inputValue)
}
openOnClick
selectionBehavior="replace"
skipAnimationOnMount={true}
@@ -244,7 +217,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No topics found</Combobox.Empty>
{topicItemsCollection.items.map((item) => (
{topicItems.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
<Combobox.ItemIndicator />

View File

@@ -1,6 +1,8 @@
import { useState } from "react";
import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi";
import type { components } from "../../reflector-api";
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
import { useTranscriptUpdate } from "../../lib/apiHooks";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
@@ -14,24 +16,27 @@ const TranscriptTitle = (props: TranscriptTitle) => {
const [displayedTitle, setDisplayedTitle] = useState(props.title);
const [preEditTitle, setPreEditTitle] = useState(props.title);
const [isEditing, setIsEditing] = useState(false);
const api = useApi();
const updateTranscriptMutation = useTranscriptUpdate();
const updateTitle = async (newTitle: string, transcriptId: string) => {
if (!api) return;
try {
const requestBody: UpdateTranscript = {
title: newTitle,
};
const updatedTranscript = await api?.v1TranscriptUpdate({
transcriptId,
requestBody,
await updateTranscriptMutation.mutateAsync({
params: {
path: { transcript_id: transcriptId },
},
body: requestBody,
});
if (props.onUpdate) {
props.onUpdate(newTitle);
}
console.log("Updated transcript:", updatedTranscript);
console.log("Updated transcript title:", newTitle);
} catch (err) {
console.error("Failed to update transcript:", err);
// Revert title on error
setDisplayedTitle(preEditTitle);
}
};

View File

@@ -1,6 +1,7 @@
import { useContext, useEffect, useState } from "react";
import { DomainContext } from "../../domainContext";
import getApi from "../../lib/useApi";
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;
@@ -17,14 +18,16 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
const [audioLoadingError, setAudioLoadingError] = useState<null | string>(
null,
);
const [transcriptMetadataLoading, setTranscriptMetadataLoading] =
useState<boolean>(true);
const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] =
useState<string | null>(null);
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
const api = getApi();
const { api_url } = useContext(DomainContext);
const accessTokenInfo = api?.httpRequest?.config?.TOKEN;
const auth = useAuth();
const accessTokenInfo =
auth.status === "authenticated" ? auth.accessToken : null;
const {
data: transcript,
isLoading: transcriptMetadataLoading,
error: transcriptError,
} = useTranscriptGet(later ? null : transcriptId);
const [serviceWorker, setServiceWorker] =
useState<ServiceWorkerRegistration | null>(null);
@@ -52,72 +55,50 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => {
if (!transcriptId || !api || later) return;
if (!transcriptId || later || !transcript) return;
let stopped = false;
let audioElement: HTMLAudioElement | null = null;
let handleCanPlay: (() => void) | null = null;
let handleError: (() => void) | null = null;
setTranscriptMetadataLoading(true);
setAudioLoading(true);
// First fetch transcript info to check if audio is deleted
api
.v1TranscriptGet({ transcriptId })
.then((transcript) => {
if (stopped) {
return;
}
const deleted = transcript.audio_deleted || false;
setAudioDeleted(deleted);
const deleted = transcript.audio_deleted || false;
setAudioDeleted(deleted);
setTranscriptMetadataLoadingError(null);
if (deleted) {
// Audio is deleted, don't attempt to load it
setMedia(null);
setAudioLoadingError(null);
setAudioLoading(false);
return;
}
if (deleted) {
// Audio is deleted, don't attempt to load it
setMedia(null);
setAudioLoadingError(null);
setAudioLoading(false);
return;
}
// Audio is not deleted, proceed to load it
audioElement = document.createElement("audio");
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
// Audio is not deleted, proceed to load it
audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
handleCanPlay = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError(null);
};
handleCanPlay = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError(null);
};
handleError = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError("Failed to load audio");
};
handleError = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError("Failed to load audio");
};
audioElement.addEventListener("canplay", handleCanPlay);
audioElement.addEventListener("error", handleError);
audioElement.addEventListener("canplay", handleCanPlay);
audioElement.addEventListener("error", handleError);
if (!stopped) {
setMedia(audioElement);
}
})
.catch((error) => {
if (stopped) return;
console.error("Failed to fetch transcript:", error);
setAudioDeleted(null);
setTranscriptMetadataLoadingError(error.message);
setAudioLoading(false);
})
.finally(() => {
if (stopped) return;
setTranscriptMetadataLoading(false);
});
if (!stopped) {
setMedia(audioElement);
}
return () => {
stopped = true;
@@ -128,14 +109,18 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
}, [transcriptId, api, later, api_url]);
}, [transcriptId, transcript, later]);
const getNow = () => {
setLater(false);
};
const loading = audioLoading || transcriptMetadataLoading;
const error = audioLoadingError || transcriptMetadataLoadingError;
const error =
audioLoadingError ||
(transcriptError
? (transcriptError as any).message || String(transcriptError)
: null);
return { media, loading, error, getNow, audioDeleted };
};

View File

@@ -1,8 +1,6 @@
import { useEffect, useState } from "react";
import { Participant } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
import type { components } from "../../reflector-api";
type Participant = components["schemas"]["Participant"];
import { useTranscriptParticipants } from "../../lib/apiHooks";
type ErrorParticipants = {
error: Error;
@@ -29,46 +27,38 @@ export type UseParticipants = (
) & { refetch: () => void };
const useParticipants = (transcriptId: string): UseParticipants => {
const [response, setResponse] = useState<Participant[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [count, setCount] = useState(0);
const {
data: response,
isLoading: loading,
error,
refetch,
} = useTranscriptParticipants(transcriptId || null);
const refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
// Type-safe return based on state
if (error) {
return {
error: error as Error,
loading: false,
response: null,
refetch,
} satisfies ErrorParticipants & { refetch: () => void };
}
useEffect(() => {
if (!transcriptId || !api) return;
if (loading || !response) {
return {
response: response || null,
loading: true,
error: null,
refetch,
} satisfies LoadingParticipants & { refetch: () => void };
}
setLoading(true);
api
.v1TranscriptGetParticipants({ transcriptId })
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Participants Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the participants");
} else {
setError(error);
}
setErrorState(error);
setResponse(null);
setLoading(false);
});
}, [transcriptId, !api, count]);
return { response, loading, error, refetch } as UseParticipants;
return {
response,
loading: false,
error: null,
refetch,
} satisfies SuccessParticipants & { refetch: () => void };
};
export default useParticipants;

View File

@@ -1,123 +0,0 @@
// this hook is not great, we want to substitute it with a proper state management solution that is also not re-invention
import { useEffect, useRef, useState } from "react";
import { SearchResult, SourceKind } from "../../api";
import useApi from "../../lib/useApi";
import {
PaginationPage,
paginationPageTo0Based,
} from "../browse/_components/Pagination";
interface SearchFilters {
roomIds: readonly string[] | null;
sourceKind: SourceKind | null;
}
const EMPTY_SEARCH_FILTERS: SearchFilters = {
roomIds: null,
sourceKind: null,
};
type UseSearchTranscriptsOptions = {
pageSize: number;
page: PaginationPage;
};
interface UseSearchTranscriptsReturn {
results: SearchResult[];
totalCount: number;
isLoading: boolean;
error: unknown;
reload: () => void;
}
function hashEffectFilters(filters: SearchFilters): string {
return JSON.stringify(filters);
}
export function useSearchTranscripts(
query: string = "",
filters: SearchFilters = EMPTY_SEARCH_FILTERS,
options: UseSearchTranscriptsOptions = {
pageSize: 20,
page: PaginationPage(1),
},
): UseSearchTranscriptsReturn {
const { pageSize, page } = options;
const [reloadCount, setReloadCount] = useState(0);
const api = useApi();
const abortControllerRef = useRef<AbortController>();
const [data, setData] = useState<{ results: SearchResult[]; total: number }>({
results: [],
total: 0,
});
const [error, setError] = useState<any>();
const [isLoading, setIsLoading] = useState(false);
const filterHash = hashEffectFilters(filters);
useEffect(() => {
if (!api) {
setData({ results: [], total: 0 });
setError(undefined);
setIsLoading(false);
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
const performSearch = async () => {
setIsLoading(true);
try {
const response = await api.v1TranscriptsSearch({
q: query || "",
limit: pageSize,
offset: paginationPageTo0Based(page) * pageSize,
roomId: filters.roomIds?.[0],
sourceKind: filters.sourceKind || undefined,
});
if (abortController.signal.aborted) return;
setData(response);
setError(undefined);
} catch (err: unknown) {
if ((err as Error).name === "AbortError") {
return;
}
if (abortController.signal.aborted) {
console.error("Aborted search but error", err);
return;
}
setError(err);
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
};
performSearch().then(() => {});
return () => {
abortController.abort();
};
}, [api, query, page, filterHash, pageSize, reloadCount]);
return {
results: data.results,
totalCount: data.total,
isLoading,
error,
reload: () => setReloadCount(reloadCount + 1),
};
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useState } from "react";
import type { components } from "../../reflector-api";
import { useTranscriptTopicsWithWordsPerSpeaker } from "../../lib/apiHooks";
import { GetTranscriptTopicWithWordsPerSpeaker } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type GetTranscriptTopicWithWordsPerSpeaker =
components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"];
type ErrorTopicWithWords = {
error: Error;
@@ -33,47 +32,40 @@ const useTopicWithWords = (
topicId: string | undefined,
transcriptId: string,
): UseTopicWithWords => {
const [response, setResponse] =
useState<GetTranscriptTopicWithWordsPerSpeaker | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const {
data: response,
isLoading: loading,
error,
refetch,
} = useTranscriptTopicsWithWordsPerSpeaker(
transcriptId || null,
topicId || null,
);
const [count, setCount] = useState(0);
if (error) {
return {
error: error as Error,
loading: false,
response: null,
refetch,
} satisfies ErrorTopicWithWords & { refetch: () => void };
}
const refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
if (loading || !response) {
return {
response: response || null,
loading: true,
error: false,
refetch,
} satisfies LoadingTopicWithWords & { refetch: () => void };
}
useEffect(() => {
if (!transcriptId || !topicId || !api) return;
setLoading(true);
api
.v1TranscriptGetTopicsWithWordsPerSpeaker({ transcriptId, topicId })
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Topics with words Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the topics with words");
} else {
setError(error);
}
setErrorState(error);
});
}, [transcriptId, !api, topicId, count]);
return { response, loading, error, refetch } as UseTopicWithWords;
return {
response,
loading: false,
error: null,
refetch,
} satisfies SuccessTopicWithWords & { refetch: () => void };
};
export default useTopicWithWords;

View File

@@ -1,10 +1,7 @@
import { useEffect, useState } from "react";
import { useTranscriptTopics } from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
import { useError } from "../../(errors)/errorContext";
import { Topic } from "./webSocketTypes";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
import { GetTranscriptTopic } from "../../api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
type TranscriptTopics = {
topics: GetTranscriptTopic[] | null;
@@ -13,34 +10,13 @@ type TranscriptTopics = {
};
const useTopics = (id: string): TranscriptTopics => {
const [topics, setTopics] = useState<Topic[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
useEffect(() => {
if (!id || !api) return;
const { data: topics, isLoading: loading, error } = useTranscriptTopics(id);
setLoading(true);
api
.v1TranscriptGetTopics({ transcriptId: id })
.then((result) => {
setTopics(result);
setLoading(false);
console.debug("Transcript topics loaded:", result);
})
.catch((err) => {
setErrorState(err);
const shouldShowHuman = shouldShowError(err);
if (shouldShowHuman) {
setError(err, "There was an error loading the topics");
} else {
setError(err);
}
});
}, [id, !api]);
return { topics, loading, error };
return {
topics: topics || null,
loading,
error: error as Error | null,
};
};
export default useTopics;

View File

@@ -1,70 +0,0 @@
import { useEffect, useState } from "react";
import { GetTranscript } from "../../api";
import { useError } from "../../(errors)/errorContext";
import { shouldShowError } from "../../lib/errorUtils";
import useApi from "../../lib/useApi";
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 [response, setResponse] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const [reload, setReload] = useState(0);
const { setError } = useError();
const api = useApi();
const reloadHandler = () => setReload((prev) => prev + 1);
useEffect(() => {
if (!id || !api) return;
if (!response) {
setLoading(true);
}
api
.v1TranscriptGet({ transcriptId: id })
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Transcript Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the transcript");
} else {
setError(error);
}
setErrorState(error);
});
}, [id, !api, reload]);
return { response, loading, error, reload: reloadHandler } as
| ErrorTranscript
| LoadingTranscript
| SuccessTranscript;
};
export default useTranscript;

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { AudioWaveform } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
import type { components } from "../../reflector-api";
import { useTranscriptWaveform } from "../../lib/apiHooks";
type AudioWaveform = components["schemas"]["AudioWaveform"];
type AudioWaveFormResponse = {
waveform: AudioWaveform | null;
@@ -11,35 +10,17 @@ type AudioWaveFormResponse = {
};
const useWaveform = (id: string, skip: boolean): AudioWaveFormResponse => {
const [waveform, setWaveform] = useState<AudioWaveform | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const {
data: waveform,
isLoading: loading,
error,
} = useTranscriptWaveform(skip ? null : id);
useEffect(() => {
if (!id || !api || skip) {
setLoading(false);
setErrorState(null);
setWaveform(null);
return;
}
setLoading(true);
setErrorState(null);
api
.v1TranscriptGetAudioWaveform({ transcriptId: id })
.then((result) => {
setWaveform(result);
setLoading(false);
console.debug("Transcript waveform loaded:", result);
})
.catch((err) => {
setErrorState(err);
setLoading(false);
});
}, [id, api, skip]);
return { waveform, loading, error };
return {
waveform: waveform || null,
loading,
error: error as Error | null,
};
};
export default useWaveform;

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import Peer from "simple-peer";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { RtcOffer } from "../../api";
import { useTranscriptWebRTC } from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
type RtcOffer = components["schemas"]["RtcOffer"];
const useWebRTC = (
stream: MediaStream | null,
@@ -10,10 +11,10 @@ const useWebRTC = (
): Peer => {
const [peer, setPeer] = useState<Peer | null>(null);
const { setError } = useError();
const api = useApi();
const { mutateAsync: mutateWebRtcTranscriptAsync } = useTranscriptWebRTC();
useEffect(() => {
if (!stream || !transcriptId || !api) {
if (!stream || !transcriptId) {
return;
}
@@ -24,7 +25,7 @@ const useWebRTC = (
try {
p = new Peer({ initiator: true, stream: stream });
} catch (error) {
setError(error, "Error creating WebRTC");
setError(error as Error, "Error creating WebRTC");
return;
}
@@ -32,26 +33,31 @@ const useWebRTC = (
setError(new Error(`WebRTC error: ${err}`));
});
p.on("signal", (data: any) => {
if (!api) return;
p.on("signal", async (data: any) => {
if ("sdp" in data) {
const rtcOffer: RtcOffer = {
sdp: data.sdp,
type: data.type,
};
api
.v1TranscriptRecordWebrtc({ transcriptId, requestBody: rtcOffer })
.then((answer) => {
try {
p.signal(answer);
} catch (error) {
setError(error);
}
})
.catch((error) => {
setError(error, "Error loading WebRTCOffer");
try {
const answer = await mutateWebRtcTranscriptAsync({
params: {
path: {
transcript_id: transcriptId,
},
},
body: rtcOffer,
});
try {
p.signal(answer);
} catch (error) {
setError(error as Error);
}
} catch (error) {
setError(error as Error, "Error loading WebRTCOffer");
}
}
});
@@ -63,7 +69,7 @@ const useWebRTC = (
return () => {
p.destroy();
};
}, [stream, transcriptId, !api]);
}, [stream, transcriptId, mutateWebRtcTranscriptAsync]);
return peer;
};

View File

@@ -1,9 +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 { AudioWaveform, GetTranscriptSegmentTopic } from "../../api";
import useApi from "../../lib/useApi";
import type { components } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"];
type GetTranscriptSegmentTopic =
components["schemas"]["GetTranscriptSegmentTopic"];
import { useQueryClient } from "@tanstack/react-query";
import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
export type UseWebSockets = {
transcriptTextLive: string;
@@ -12,7 +15,7 @@ export type UseWebSockets = {
title: string;
topics: Topic[];
finalSummary: FinalSummary;
status: Status;
status: Status | null;
waveform: AudioWaveform | null;
duration: number | null;
};
@@ -30,11 +33,10 @@ 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 } = useContext(DomainContext);
const api = useApi();
const queryClient = useQueryClient();
const [accumulatedText, setAccumulatedText] = useState<string>("");
@@ -105,6 +107,13 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
title: "Topic 1: Introduction to Quantum Mechanics",
transcript:
"A brief overview of quantum mechanics and its principles.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
],
},
{
id: "2",
@@ -315,11 +324,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}
};
if (!transcriptId || !api) return;
if (!transcriptId) return;
api?.v1TranscriptGetWebsocketEvents({ transcriptId }).then((result) => {});
const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`;
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url);
ws.onopen = () => {
@@ -361,6 +368,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
return [...prevTopics, topic];
});
console.debug("TOPIC event:", message.data);
// Invalidate topics query to sync with WebSocket data
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/topics",
{
params: { path: { transcript_id: transcriptId } },
},
).queryKey,
});
break;
case "FINAL_SHORT_SUMMARY":
@@ -370,6 +387,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
case "FINAL_LONG_SUMMARY":
if (message.data) {
setFinalSummary(message.data);
// Invalidate transcript query to sync summary
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: { path: { transcript_id: transcriptId } },
},
).queryKey,
});
}
break;
@@ -377,6 +404,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
console.debug("FINAL_TITLE event:", message.data);
if (message.data) {
setTitle(message.data.title);
// Invalidate transcript query to sync title
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: { path: { transcript_id: transcriptId } },
},
).queryKey,
});
}
break;
@@ -434,6 +471,11 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
break;
case 1001: // Navigate away
break;
case 1006: // Closed by client Chrome
console.warn(
"WebSocket closed by client, likely duplicated connection in react dev mode",
);
break;
default:
setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`),
@@ -450,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
return () => {
ws.close();
};
}, [transcriptId, !api]);
}, [transcriptId]);
return {
transcriptTextLive,

View File

@@ -1,4 +1,7 @@
import { GetTranscriptTopic } from "../../api";
import type { components } from "../../reflector-api";
import type { TranscriptStatus } from "../../lib/transcript";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
export type Topic = GetTranscriptTopic;
@@ -11,7 +14,7 @@ export type FinalSummary = {
};
export type Status = {
value: string;
value: TranscriptStatus;
};
export type TranslatedTopic = {