Remove domain segment

This commit is contained in:
2024-09-01 01:20:00 +02:00
parent 83756116a6
commit b019e81b9b
52 changed files with 32 additions and 51 deletions

View File

@@ -0,0 +1,303 @@
"use client";
import React, { useEffect, useState } from "react";
import { GetTranscript } from "../../api";
import Pagination from "./pagination";
import NextLink from "next/link";
import { FaArrowRotateRight, FaGear } from "react-icons/fa6";
import { FaCheck, FaTrash, FaStar, FaMicrophone } from "react-icons/fa";
import { MdError } from "react-icons/md";
import useTranscriptList from "../transcripts/useTranscriptList";
import { formatTimeMs } from "../../lib/time";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { FaEllipsisVertical } from "react-icons/fa6";
import {
Flex,
Spinner,
Heading,
Button,
Card,
Link,
CardBody,
Stack,
Text,
Icon,
Grid,
IconButton,
Spacer,
Menu,
MenuButton,
MenuItem,
MenuList,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Tooltip,
} from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";
import { ExpandableText } from "../../lib/expandableText";
// import { useFiefUserinfo } from "@fief/fief/nextjs/react";
export default function TranscriptBrowser() {
const [page, setPage] = useState<number>(1);
const { loading, response, refetch } = useTranscriptList(page);
const [deletionLoading, setDeletionLoading] = useState(false);
const api = useApi();
const { setError } = useError();
const cancelRef = React.useRef(null);
const [transcriptToDeleteId, setTranscriptToDeleteId] =
React.useState<string>();
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
// Todo: fief add name field to userinfo
// const user = useFiefUserinfo();
// console.log(user);
useEffect(() => {
setDeletedItemIds([]);
}, [page, response]);
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Spinner size="xl" />
</Flex>
);
if (!loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Text>
No transcripts found, but you can&nbsp;
<Link href="/transcripts/new" className="underline">
record a meeting
</Link>
&nbsp;to get started.
</Text>
</Flex>
);
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const handleDeleteTranscript = (transcriptId) => (e) => {
e.stopPropagation();
if (api && !deletionLoading) {
setDeletionLoading(true);
api
.v1TranscriptDelete({ transcriptId })
.then(() => {
refetch();
setDeletionLoading(false);
refetch();
onCloseDeletion();
setDeletedItemIds((deletedItemIds) => [
deletedItemIds,
...transcriptId,
]);
})
.catch((err) => {
setDeletionLoading(false);
setError(err, "There was an error deleting the transcript");
});
}
};
const handleProcessTranscript = (transcriptId) => (e) => {
if (api) {
api
.v1TranscriptProcess({ transcriptId })
.then((result) => {
const status = (result as any).status;
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");
});
}
};
return (
<Flex
maxW="container.xl"
flexDir="column"
margin="auto"
gap={2}
overflowY="scroll"
maxH="100%"
>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
>
{/* <Heading>{user?.fields?.name}'s Meetings</Heading> */}
<Heading>Your Meetings</Heading>
{loading || (deletionLoading && <Spinner></Spinner>)}
<Spacer />
<Pagination
page={page}
setPage={setPage}
total={response?.total || 0}
size={response?.size || 0}
/>
<Button colorScheme="blue" rightIcon={<PlusSquareIcon />}>
New Meeting
</Button>
</Flex>
<Grid
templateColumns={{
base: "repeat(1, 1fr)",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={{
base: 2,
lg: 4,
}}
maxH="100%"
overflowY={"scroll"}
mb="4"
>
{response?.items
.filter((i) => !deletedItemIds?.includes(i.id))
.map((item: GetTranscript) => (
<Card key={item.id} border="gray.light" variant="outline">
<CardBody>
<Flex align={"center"} ml="-6px">
{item.status == "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} mr="2" />
</span>
</Tooltip>
)}
{item.status == "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.primary" as={MdError} mr="2" />
</span>
</Tooltip>
)}
{item.status == "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} mr="2" />
</span>
</Tooltip>
)}
{item.status == "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon
color="grey.primary"
as={FaGear}
mr="2"
transition={"all 2s ease"}
transform={"rotate(0deg)"}
_hover={{ transform: "rotate(360deg)" }}
/>
</span>
</Tooltip>
)}
{item.status == "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.primary" as={FaMicrophone} mr="2" />
</span>
</Tooltip>
)}
<Heading size="md">
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
noOfLines={2}
>
{item.title || item.name || "Unnamed Transcript"}
</Link>
</Heading>
<Spacer />
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
isDisabled={deletionLoading}
onClick={() => setTranscriptToDeleteId(item.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
<MenuItem
isDisabled={item.status === "idle"}
onClick={handleProcessTranscript(item.id)}
icon={<FaArrowRotateRight />}
>
Process
</MenuItem>
<AlertDialog
isOpen={transcriptToDeleteId === item.id}
leastDestructiveRef={cancelRef}
onClose={onCloseDeletion}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete{" "}
{item.title || item.name || "Unnamed Transcript"}
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action
afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onCloseDeletion}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={handleDeleteTranscript(item.id)}
ml={3}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</MenuList>
</Menu>
</Flex>
<Stack mt="6" spacing="3">
<Text fontSize="small">
{new Date(item.created_at).toLocaleString("en-US")}
{"\u00A0"}-{"\u00A0"}
{formatTimeMs(item.duration)}
</Text>
<ExpandableText noOfLines={5}>
{item.short_summary}
</ExpandableText>
</Stack>
</CardBody>
</Card>
))}
</Grid>
</Flex>
);
}

View File

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

View File

@@ -0,0 +1,346 @@
"use client";
import {
Button,
Card,
CardBody,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Spinner,
useDisclosure,
VStack,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Checkbox,
} from "@chakra-ui/react";
import { useContext, useEffect, useState } from "react";
import { Container } from "@chakra-ui/react";
import { FaEllipsisVertical, FaTrash, FaPencil } from "react-icons/fa6";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { DomainContext } from "../../domainContext";
import { Select, Options, OptionBase } from "chakra-react-select";
interface Stream {
id: number;
name: string;
topics: string[];
}
interface SelectOption extends OptionBase {
label: string;
value: string;
}
const RESERVED_PATHS = ["browse", "rooms", "transcripts"];
export default function RoomsList() {
const { isOpen, onOpen, onClose } = useDisclosure();
const [room, setRoom] = useState({
name: "",
zulipAutoPost: false,
zulipStream: "",
zulipTopic: "",
});
const [isEditing, setIsEditing] = useState(false);
const [editRoomId, setEditRoomId] = useState("");
const api = useApi();
const [page, setPage] = useState<number>(1);
const { loading, response, refetch } = useRoomList(page);
const [streams, setStreams] = useState<Stream[]>([]);
const [error, setError] = useState("");
const { zulip_streams } = useContext(DomainContext);
useEffect(() => {
const fetchZulipStreams = async () => {
try {
const response = await fetch(zulip_streams + "/streams.json");
if (!response.ok) {
throw new Error("Network response was not ok");
}
let data = await response.json();
data = data.sort((a: Stream, b: Stream) =>
a.name.localeCompare(b.name),
);
setStreams(data);
} catch (err) {
console.error("Error fetching streams:", err);
}
};
if (room.zulipAutoPost) {
fetchZulipStreams();
}
}, [room.zulipAutoPost]);
const streamOptions: Options<SelectOption> = streams.map((stream) => {
return { label: stream.name, value: stream.name };
});
const topicOptions =
streams
.find((stream) => stream.name === room.zulipStream)
?.topics.map((topic) => ({ label: topic, value: topic })) || [];
const handleSaveRoom = async () => {
try {
if (RESERVED_PATHS.includes(room.name)) {
setError("This room name is reserved. Please choose another name.");
return;
}
if (isEditing) {
await api?.v1RoomsUpdate({
roomId: editRoomId,
requestBody: {
name: room.name,
zulip_auto_post: room.zulipAutoPost,
zulip_stream: room.zulipStream,
zulip_topic: room.zulipTopic,
},
});
} else {
await api?.v1RoomsCreate({
requestBody: {
name: room.name,
zulip_auto_post: room.zulipAutoPost,
zulip_stream: room.zulipStream,
zulip_topic: room.zulipTopic,
},
});
}
setRoom({
name: "",
zulipAutoPost: false,
zulipStream: "",
zulipTopic: "",
});
setIsEditing(false);
setEditRoomId("");
setError("");
refetch();
} catch (err) {
console.error(err);
}
onClose();
};
const handleEditRoom = (roomId, roomData) => {
setRoom({
name: roomData.name,
zulipAutoPost: roomData.zulip_auto_post,
zulipStream: roomData.zulip_stream,
zulipTopic: roomData.zulip_topic,
});
setEditRoomId(roomId);
setIsEditing(true);
onOpen();
};
const handleDeleteRoom = async (roomId: string) => {
try {
await api?.v1RoomsDelete({
roomId,
});
refetch();
} catch (err) {
console.error(err);
}
};
const handleRoomChange = (e) => {
let { name, value, type, checked } = e.target;
if (name === "name") {
value = value
.replace(/[^a-zA-Z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.toLowerCase();
setError("");
}
setRoom({
...room,
[name]: type === "checkbox" ? checked : value,
});
};
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Spinner size="xl" />
</Flex>
);
return (
<>
<Container maxW={"container.lg"}>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
mb={2}
>
<Heading>Rooms</Heading>
<Spacer />
<Button
colorScheme="blue"
onClick={() => {
setIsEditing(false);
setRoom({
name: "",
zulipAutoPost: false,
zulipStream: "",
zulipTopic: "",
});
setError("");
onOpen();
}}
>
Add Room
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{isEditing ? "Edit Room" : "Add Room"}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<FormLabel>Room name</FormLabel>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<FormHelperText>
No spaces or special characters allowed
</FormHelperText>
{error && <Text color="red.500">{error}</Text>}
</FormControl>
<FormControl mt={8}>
<Checkbox
name="zulipAutoPost"
isChecked={room.zulipAutoPost}
onChange={handleRoomChange}
>
Automatically post transcription to Zulip
</Checkbox>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip stream</FormLabel>
<Select
name="zulipStream"
options={streamOptions}
placeholder="Select stream"
value={{ label: room.zulipStream, value: room.zulipStream }}
onChange={(newValue) =>
setRoom({
...room,
zulipStream: newValue!.value,
zulipTopic: "",
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip topic</FormLabel>
<Select
name="zulipTopic"
options={topicOptions}
placeholder="Select topic"
value={{ label: room.zulipTopic, value: room.zulipTopic }}
onChange={(newValue) =>
setRoom({
...room,
zulipTopic: newValue!.value,
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
</Button>
<Button
colorScheme="blue"
onClick={handleSaveRoom}
isDisabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}
>
{isEditing ? "Save" : "Add"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Flex>
<VStack>
{response?.items && response.items.length > 0 ? (
response.items.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Text>No rooms found</Text>
</Flex>
)}
</VStack>
</Container>
</>
);
}

View File

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

@@ -0,0 +1,123 @@
"use client";
import { useState } 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 { SelectedText, selectedTextIsTimeSlice } from "./types";
import useApi from "../../../../lib/useApi";
import useTranscript from "../../useTranscript";
import { useError } from "../../../../(errors)/errorContext";
import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react";
export type TranscriptCorrect = {
params: {
transcriptId: string;
};
};
export default function TranscriptCorrect({
params: { transcriptId },
}: TranscriptCorrect) {
const api = useApi();
const transcript = useTranscript(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState<SelectedText>();
const [selectedText, _sst] = stateSelectedText;
const topicWithWords = useTopicWithWords(currentTopic?.id, transcriptId);
const participants = useParticipants(transcriptId);
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");
});
}
};
return (
<Grid
templateRows="auto minmax(0, 1fr)"
h="100%"
maxW={{ lg: "container.lg" }}
mx="auto"
minW={{ base: "100%", lg: "container.lg" }}
>
<Box display="flex" flexDir="column" minW="100%" mb={{ base: 4, lg: 10 }}>
<TopicHeader
minW="100%"
stateCurrentTopic={stateCurrentTopic}
transcriptId={transcriptId}
topicWithWordsLoading={topicWithWords.loading}
/>
<TopicPlayer
transcriptId={transcriptId}
selectedTime={
selectedTextIsTimeSlice(selectedText) ? selectedText : undefined
}
topicTime={
currentTopic
? {
start: currentTopic?.timestamp,
end: currentTopic?.timestamp + (currentTopic?.duration || 0),
}
: undefined
}
/>
</Box>
<Grid
templateColumns={{
base: "minmax(0, 1fr)",
md: "4fr 3fr",
lg: "2fr 1fr",
}}
templateRows={{
base: "repeat(2, minmax(0, 1fr)) auto",
md: "minmax(0, 1fr)",
}}
gap={{ base: "2", md: "4", lg: "4" }}
h="100%"
maxH="100%"
w="100%"
>
<TopicWords
stateSelectedText={stateSelectedText}
participants={participants}
topicWithWords={topicWithWords}
mb={{ md: "-3rem" }}
/>
<ParticipantList
{...{
transcriptId,
participants,
topicWithWords,
stateSelectedText,
}}
/>
</Grid>
{transcript.response && !transcript.response?.reviewed && (
<div className="flex flex-row justify-end">
<button
className="p-2 px-4 rounded bg-green-400"
onClick={markAsDone}
>
Done
</button>
</div>
)}
</Grid>
);
}

View File

@@ -0,0 +1,479 @@
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 { UseParticipants } from "../../useParticipants";
import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./types";
import { useError } from "../../../../(errors)/errorContext";
import {
Box,
Button,
Flex,
Text,
UnorderedList,
Input,
Kbd,
Spinner,
ListItem,
Grid,
} from "@chakra-ui/react";
type ParticipantList = {
participants: UseParticipants;
transcriptId: string;
topicWithWords: any;
stateSelectedText: any;
};
const ParticipantList = ({
transcriptId,
participants,
topicWithWords,
stateSelectedText,
}: ParticipantList) => {
const api = useApi();
const { setError } = useError();
const [loading, setLoading] = useState(false);
const [participantInput, setParticipantInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [selectedText, setSelectedText] = stateSelectedText;
const [selectedParticipant, setSelectedParticipant] = useState<Participant>();
const [action, setAction] = useState<
"Create" | "Create to rename" | "Create and assign" | "Rename" | null
>(null);
const [oneMatch, setOneMatch] = useState<Participant>();
useEffect(() => {
if (participants.response) {
if (selectedTextIsSpeaker(selectedText)) {
inputRef.current?.focus();
const participant = participants.response.find(
(p) => p.speaker == selectedText,
);
if (participant) {
setParticipantInput(participant.name);
setOneMatch(undefined);
setSelectedParticipant(participant);
setAction("Rename");
} else {
setSelectedParticipant(participant);
setParticipantInput("");
setOneMatch(undefined);
setAction("Create to rename");
}
}
if (selectedTextIsTimeSlice(selectedText)) {
inputRef.current?.focus();
setParticipantInput("");
setOneMatch(undefined);
setAction("Create and assign");
setSelectedParticipant(undefined);
}
if (typeof selectedText == "undefined") {
inputRef.current?.blur();
setSelectedParticipant(undefined);
setAction(null);
}
}
}, [selectedText, !participants.response]);
useEffect(() => {
document.onkeyup = (e) => {
if (loading || participants.loading || topicWithWords.loading) return;
if (e.key === "Enter" && e.ctrlKey) {
if (oneMatch) {
if (
action == "Create and assign" &&
selectedTextIsTimeSlice(selectedText)
) {
assignTo(oneMatch)().catch(() => {});
} else if (
action == "Create to rename" &&
selectedTextIsSpeaker(selectedText)
) {
mergeSpeaker(selectedText, oneMatch)();
}
}
} else if (e.key === "Enter") {
doAction();
}
};
});
const onSuccess = () => {
topicWithWords.refetch();
participants.refetch();
setLoading(false);
setAction(null);
setSelectedText(undefined);
setSelectedParticipant(undefined);
setParticipantInput("");
setOneMatch(undefined);
inputRef?.current?.blur();
};
const assignTo =
(participant) => async (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (loading || participants.loading || topicWithWords.loading) return;
if (!selectedTextIsTimeSlice(selectedText)) return;
setLoading(true);
try {
await api?.v1TranscriptAssignSpeaker({
transcriptId,
requestBody: {
participant: participant.id,
timestamp_from: selectedText.start,
timestamp_to: selectedText.end,
},
});
onSuccess();
} catch (error) {
setError(error, "There was an error assigning");
setLoading(false);
throw error;
}
};
const mergeSpeaker =
(speakerFrom, participantTo: Participant) => async () => {
if (loading || participants.loading || topicWithWords.loading) return;
setLoading(true);
if (participantTo.speaker) {
try {
await api?.v1TranscriptMergeSpeaker({
transcriptId,
requestBody: {
speaker_from: speakerFrom,
speaker_to: participantTo.speaker,
},
});
onSuccess();
} catch (error) {
setError(error, "There was an error merging");
setLoading(false);
}
} else {
try {
await api?.v1TranscriptUpdateParticipant({
transcriptId,
participantId: participantTo.id,
requestBody: { speaker: speakerFrom },
});
onSuccess();
} catch (error) {
setError(error, "There was an error merging (update)");
setLoading(false);
}
}
};
const doAction = async (e?) => {
e?.preventDefault();
e?.stopPropagation();
if (
loading ||
participants.loading ||
topicWithWords.loading ||
!participants.response
)
return;
if (action == "Rename" && selectedTextIsSpeaker(selectedText)) {
const participant = participants.response.find(
(p) => p.speaker == selectedText,
);
if (participant && participant.name !== participantInput) {
setLoading(true);
api
?.v1TranscriptUpdateParticipant({
transcriptId,
participantId: participant.id,
requestBody: {
name: participantInput,
},
})
.then(() => {
participants.refetch();
setLoading(false);
setAction(null);
})
.catch((e) => {
setError(e, "There was an error renaming");
setLoading(false);
});
}
} else if (
action == "Create to rename" &&
selectedTextIsSpeaker(selectedText)
) {
setLoading(true);
api
?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
name: participantInput,
speaker: selectedText,
},
})
.then(() => {
participants.refetch();
setParticipantInput("");
setOneMatch(undefined);
setLoading(false);
})
.catch((e) => {
setError(e, "There was an error creating");
setLoading(false);
});
} else if (
action == "Create and assign" &&
selectedTextIsTimeSlice(selectedText)
) {
setLoading(true);
try {
const participant = await api?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
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);
}
} else if (action == "Create") {
setLoading(true);
api
?.v1TranscriptAddParticipant({
transcriptId,
requestBody: {
name: participantInput,
},
})
.then(() => {
participants.refetch();
setParticipantInput("");
setLoading(false);
inputRef.current?.focus();
})
.catch((e) => {
setError(e, "There was an error creating");
setLoading(false);
});
}
};
const deleteParticipant = (participantId) => (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);
});
};
const selectParticipant = (participant) => (e) => {
setSelectedParticipant(participant);
setSelectedText(participant.speaker);
setAction("Rename");
setParticipantInput(participant.name);
oneMatch && setOneMatch(undefined);
};
const clearSelection = () => {
setSelectedParticipant(undefined);
setSelectedText(undefined);
setAction(null);
setParticipantInput("");
oneMatch && setOneMatch(undefined);
};
const preventClick = (e) => {
e?.stopPropagation();
e?.preventDefault();
};
const changeParticipantInput = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replaceAll(/,|\.| /g, "");
setParticipantInput(value);
if (
value.length > 0 &&
participants.response &&
(action == "Create and assign" || action == "Create to rename")
) {
if (
participants.response.filter((p) => p.name.startsWith(value)).length ==
1
) {
setOneMatch(
participants.response.find((p) => p.name.startsWith(value)),
);
} else {
setOneMatch(undefined);
}
}
if (value.length > 0 && !action) {
setAction("Create");
}
};
const anyLoading = loading || participants.loading || topicWithWords.loading;
return (
<Box h="100%" onClick={clearSelection} width="100%">
<Grid
onClick={preventClick}
maxH="100%"
templateRows="auto minmax(0, 1fr)"
min-w="100%"
>
<Flex direction="column" p="2">
<Input
ref={inputRef}
onChange={changeParticipantInput}
value={participantInput}
mb="2"
placeholder="Participant Name"
/>
<Button
onClick={doAction}
colorScheme="blue"
disabled={!action || anyLoading}
>
{!anyLoading ? (
<>
<Kbd color="blue.500" pt="1" mr="1">
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 h-3"
/>
</Kbd>
{action || "Create"}
</>
) : (
<Spinner />
)}
</Button>
</Flex>
{participants.response && (
<UnorderedList
mx="0"
mb={{ base: 2, md: 4 }}
maxH="100%"
overflow="scroll"
>
{participants.response.map((participant: Participant) => (
<ListItem
onClick={selectParticipant(participant)}
cursor="pointer"
className={
(participantInput.length > 0 &&
selectedText &&
participant.name.startsWith(participantInput)
? "bg-blue-100 "
: "") +
(participant.id == selectedParticipant?.id
? "bg-blue-200 border"
: "")
}
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center"
borderBottom="1px"
borderColor="gray.300"
p="2"
mx="2"
_last={{ borderBottom: "0" }}
key={participant.name}
>
<Text mt="1">{participant.name}</Text>
<Box>
{action == "Create to rename" &&
!selectedParticipant &&
!loading && (
<Button
onClick={mergeSpeaker(selectedText, participant)}
colorScheme="blue"
ml="2"
size="sm"
>
{oneMatch?.id == participant.id &&
action == "Create to rename" && (
<Kbd
letterSpacing="-1px"
color="blue.500"
mr="1"
pt="3px"
>
Ctrl +&nbsp;
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 h-2"
/>
</Kbd>
)}
Merge
</Button>
)}
{selectedTextIsTimeSlice(selectedText) && !loading && (
<Button
onClick={assignTo(participant)}
colorScheme="blue"
ml="2"
size="sm"
>
{oneMatch?.id == participant.id &&
action == "Create and assign" && (
<Kbd
letterSpacing="-1px"
color="blue.500"
mr="1"
pt="3px"
>
Ctrl +&nbsp;
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 h-2"
/>
</Kbd>
)}{" "}
Assign
</Button>
)}
<Button
onClick={deleteParticipant(participant.id)}
colorScheme="blue"
ml="2"
size="sm"
>
Delete
</Button>
</Box>
</ListItem>
))}
</UnorderedList>
)}
</Grid>
</Box>
);
};
export default ParticipantList;

View File

@@ -0,0 +1,29 @@
export default ({ playing }) => (
<div className="flex justify-between w-14 h-6">
<div
className={`bg-blue-400 rounded w-2 ${
playing ? "animate-wave-quiet" : ""
}`}
></div>
<div
className={`bg-blue-400 rounded w-2 ${
playing ? "animate-wave-normal" : ""
}`}
></div>
<div
className={`bg-blue-400 rounded w-2 ${
playing ? "animate-wave-quiet" : ""
}`}
></div>
<div
className={`bg-blue-400 rounded w-2 ${
playing ? "animate-wave-loud" : ""
}`}
></div>
<div
className={`bg-blue-400 rounded w-2 ${
playing ? "animate-wave-normal" : ""
}`}
></div>
</div>
);

View File

@@ -0,0 +1,163 @@
import useTopics from "../../useTopics";
import { Dispatch, SetStateAction, useEffect } from "react";
import { GetTranscriptTopic } from "../../../../api";
import {
BoxProps,
Box,
Circle,
Heading,
Kbd,
Skeleton,
SkeletonCircle,
Flex,
} from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
type TopicHeader = {
stateCurrentTopic: [
GetTranscriptTopic | undefined,
Dispatch<SetStateAction<GetTranscriptTopic | undefined>>,
];
transcriptId: string;
topicWithWordsLoading: boolean;
};
export default function TopicHeader({
stateCurrentTopic,
transcriptId,
topicWithWordsLoading,
...chakraProps
}: TopicHeader & BoxProps) {
const [currentTopic, setCurrentTopic] = stateCurrentTopic;
const topics = useTopics(transcriptId);
useEffect(() => {
if (!topics.loading && !currentTopic) {
const sessionTopic = window.localStorage.getItem(
transcriptId + "correct",
);
if (sessionTopic && topics?.topics?.find((t) => t.id == sessionTopic)) {
setCurrentTopic(topics?.topics?.find((t) => t.id == sessionTopic));
} else {
setCurrentTopic(topics?.topics?.at(0));
}
}
}, [topics.loading]);
const number = topics.topics?.findIndex(
(topic) => topic.id == currentTopic?.id,
);
const canGoPrevious = typeof number == "number" && number > 0;
const total = topics.topics?.length;
const canGoNext = total && typeof number == "number" && number + 1 < total;
const onPrev = () => {
if (topicWithWordsLoading) return;
canGoPrevious && setCurrentTopic(topics.topics?.at(number - 1));
};
const onNext = () => {
if (topicWithWordsLoading) return;
canGoNext && setCurrentTopic(topics.topics?.at(number + 1));
};
useEffect(() => {
currentTopic?.id &&
window.localStorage.setItem(transcriptId + "correct", currentTopic?.id);
}, [currentTopic?.id]);
const keyHandler = (e) => {
if (e.key == "ArrowLeft") {
onPrev();
} else if (e.key == "ArrowRight") {
onNext();
}
};
useEffect(() => {
document.addEventListener("keyup", keyHandler);
return () => {
document.removeEventListener("keyup", keyHandler);
};
});
const isLoaded = !!(
topics.topics &&
currentTopic &&
typeof number == "number"
);
return (
<Box
display="flex"
w="100%"
justifyContent="space-between"
{...chakraProps}
>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
<Circle
as="button"
onClick={onPrev}
disabled={!canGoPrevious}
size="40px"
border="1px"
color={canGoPrevious ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
>
{canGoPrevious ? (
<Kbd>
<ChevronLeftIcon />
</Kbd>
) : (
<ChevronLeftIcon />
)}
</Circle>
</SkeletonCircle>
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
flexGrow={1}
mx={6}
>
<Flex wrap="nowrap" justifyContent="center">
<Heading size="lg" textAlign="center" noOfLines={1}>
{currentTopic?.title}{" "}
</Heading>
<Heading size="lg" ml="3">
{(number || 0) + 1}/{total}
</Heading>
</Flex>
</Skeleton>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
<Circle
as="button"
onClick={onNext}
disabled={!canGoNext}
size="40px"
border="1px"
color={canGoNext ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
>
{canGoNext ? (
<Kbd>
<ChevronRightIcon />
</Kbd>
) : (
<ChevronRightIcon />
)}
</Circle>
</SkeletonCircle>
</Box>
);
}

View File

@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import useMp3 from "../../useMp3";
import { formatTime } from "../../../../lib/time";
import SoundWaveCss from "./soundWaveCss";
import { TimeSlice } from "./types";
import {
BoxProps,
Button,
Wrap,
Text,
WrapItem,
Kbd,
Skeleton,
} from "@chakra-ui/react";
type TopicPlayer = {
transcriptId: string;
selectedTime: TimeSlice | undefined;
topicTime: TimeSlice | undefined;
};
const TopicPlayer = ({
transcriptId,
selectedTime,
topicTime,
...chakraProps
}: TopicPlayer & BoxProps) => {
const mp3 = useMp3(transcriptId);
const [isPlaying, setIsPlaying] = useState(false);
const [endTopicCallback, setEndTopicCallback] = useState<() => void>();
const [endSelectionCallback, setEndSelectionCallback] =
useState<() => void>();
const [showTime, setShowTime] = useState("");
const playButton = useRef<HTMLButtonElement>(null);
const keyHandler = (e) => {
if (e.key == " ") {
if (e.target.id != "playButton") {
if (isPlaying) {
mp3.media?.pause();
setIsPlaying(false);
} else {
mp3.media?.play();
setIsPlaying(true);
}
}
} else if (selectedTime && e.key == ",") {
playSelection();
}
};
useEffect(() => {
document.addEventListener("keyup", keyHandler);
return () => {
document.removeEventListener("keyup", keyHandler);
};
});
const calcShowTime = () => {
if (!topicTime) return;
setShowTime(
`${
mp3.media?.currentTime
? formatTime(mp3.media?.currentTime - topicTime.start)
: "00:00"
}/${formatTime(topicTime.end - topicTime.start)}`,
);
};
useEffect(() => {
let i;
if (isPlaying) {
i = setInterval(calcShowTime, 1000);
}
return () => i && clearInterval(i);
}, [isPlaying]);
useEffect(() => {
setEndTopicCallback(
() =>
function () {
if (
!topicTime ||
!mp3.media ||
!(mp3.media.currentTime >= topicTime.end)
)
return;
mp3.media.pause();
setIsPlaying(false);
mp3.media.currentTime = topicTime.start;
calcShowTime();
},
);
if (mp3.media) {
playButton.current?.focus();
mp3.media?.pause();
// there's no callback on pause but apparently changing the time while palying doesn't work... so here is a timeout
setTimeout(() => {
if (mp3.media) {
if (!topicTime) return;
mp3.media.currentTime = topicTime.start;
setShowTime(`00:00/${formatTime(topicTime.end - topicTime.start)}`);
}
}, 10);
setIsPlaying(false);
}
}, [!mp3.media, topicTime?.start, topicTime?.end]);
useEffect(() => {
endTopicCallback &&
mp3.media &&
mp3.media.addEventListener("timeupdate", endTopicCallback);
return () => {
endTopicCallback &&
mp3.media &&
mp3.media.removeEventListener("timeupdate", endTopicCallback);
};
}, [endTopicCallback]);
const playSelection = (e?) => {
e?.preventDefault();
e?.target?.blur();
if (mp3.media && selectedTime?.start !== undefined) {
mp3.media.currentTime = selectedTime.start;
calcShowTime();
setEndSelectionCallback(
() =>
function () {
if (
mp3.media &&
selectedTime.end &&
mp3.media.currentTime >= selectedTime.end
) {
mp3.media.pause();
setIsPlaying(false);
setEndSelectionCallback(() => {});
}
},
);
mp3.media.play();
setIsPlaying(true);
}
};
useEffect(() => {
endSelectionCallback &&
mp3.media &&
mp3.media.addEventListener("timeupdate", endSelectionCallback);
return () => {
endSelectionCallback &&
mp3.media &&
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
};
}, [endSelectionCallback]);
const playTopic = (e) => {
e?.preventDefault();
e?.target?.blur();
if (!topicTime) return;
if (mp3.media) {
mp3.media.currentTime = topicTime.start;
mp3.media.play();
setIsPlaying(true);
endSelectionCallback &&
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
}
};
const playCurrent = (e) => {
e.preventDefault();
e?.target?.blur();
mp3.media?.play();
setIsPlaying(true);
};
const pause = (e) => {
e.preventDefault();
e?.target?.blur();
mp3.media?.pause();
setIsPlaying(false);
};
const isLoaded = !!(mp3.media && topicTime);
return (
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
fadeDuration={1}
w={isLoaded ? "auto" : "container.md"}
margin="auto"
{...chakraProps}
>
<Wrap spacing="4" justify="center" align="center">
<WrapItem>
<SoundWaveCss playing={isPlaying} />
<Text fontSize="sm" pt="1" pl="2">
{showTime}
</Text>
</WrapItem>
<WrapItem>
<Button onClick={playTopic} colorScheme="blue">
Play from start
</Button>
</WrapItem>
<WrapItem>
{!isPlaying ? (
<Button
onClick={playCurrent}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Play
</Button>
) : (
<Button
onClick={pause}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Pause
</Button>
)}
</WrapItem>
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
<Button
disabled={!selectedTime}
onClick={playSelection}
colorScheme="blue"
>
<Kbd color="blue.600">,</Kbd>&nbsp;Play selection
</Button>
</WrapItem>
</Wrap>
</Skeleton>
);
};
export default TopicPlayer;

View File

@@ -0,0 +1,220 @@
import { Dispatch, SetStateAction, useEffect } from "react";
import { UseParticipants } from "../../useParticipants";
import { UseTopicWithWords } from "../../useTopicWithWords";
import { TimeSlice, selectedTextIsTimeSlice } from "./types";
import { BoxProps, Box, Container, Text, Spinner } from "@chakra-ui/react";
type TopicWordsProps = {
stateSelectedText: [
number | TimeSlice | undefined,
Dispatch<SetStateAction<number | TimeSlice | undefined>>,
];
participants: UseParticipants;
topicWithWords: UseTopicWithWords;
};
const topicWords = ({
stateSelectedText,
participants,
topicWithWords,
...chakraProps
}: TopicWordsProps & BoxProps) => {
const [selectedText, setSelectedText] = stateSelectedText;
useEffect(() => {
if (topicWithWords.loading && selectedTextIsTimeSlice(selectedText)) {
setSelectedText(undefined);
}
}, [topicWithWords.loading]);
const getStartTimeFromFirstNode = (node, offset, reverse) => {
// Check if the current node represents a word with a start time
if (node.parentElement?.dataset["start"]) {
// Check if the position is at the end of the word
if (node.textContent?.length == offset) {
// Try to get the start time of the next word
const nextWordStartTime =
node.parentElement.nextElementSibling?.dataset["start"];
if (nextWordStartTime) {
return nextWordStartTime;
}
// If no next word, get start of the first word in the next paragraph
const nextParaFirstWordStartTime =
node.parentElement.parentElement.nextElementSibling?.childNodes[1]
?.dataset["start"];
if (nextParaFirstWordStartTime) {
return nextParaFirstWordStartTime;
}
// Return default values based on 'reverse' flag
// If reverse is false, means the node is the last word of the topic transcript,
// so reverse should be true, and we set a high value to make sure this is not picked as the start time.
// Reverse being true never happens given how we use this function, but for consistency in case things change,
// we set a low value.
return reverse ? 0 : 9999999999999;
} else {
// Position is within the word, return start of this word
return node.parentElement.dataset["start"];
}
} else {
// Selection is on a name, return start of the next word
return node.parentElement.nextElementSibling?.dataset["start"];
}
};
const onMouseUp = (e) => {
let selection = window.getSelection();
if (
selection &&
selection.anchorNode &&
selection.focusNode &&
selection.anchorNode == selection.focusNode &&
selection.anchorOffset == selection.focusOffset
) {
setSelectedText(undefined);
selection.empty();
return;
}
if (
selection &&
selection.anchorNode &&
selection.focusNode &&
(selection.anchorNode !== selection.focusNode ||
selection.anchorOffset !== selection.focusOffset)
) {
const anchorNode = selection.anchorNode;
const anchorIsWord =
!!selection.anchorNode.parentElement?.dataset["start"];
const focusNode = selection.focusNode;
const focusIsWord = !!selection.focusNode.parentElement?.dataset["end"];
// If selected a speaker :
if (
!anchorIsWord &&
!focusIsWord &&
anchorNode.parentElement == focusNode.parentElement
) {
setSelectedText(
focusNode.parentElement?.dataset["speaker"]
? parseInt(focusNode.parentElement?.dataset["speaker"])
: undefined,
);
return;
}
const anchorStart = getStartTimeFromFirstNode(
anchorNode,
selection.anchorOffset,
false,
);
// if selection end on a word, we get the end time from the span that contains it
const focusEnd =
selection.focusOffset !== 0
? selection.focusNode.parentElement?.dataset["end"] ||
// otherwise it was a name and we get the end of the last word of the previous paragraph
(
selection.focusNode.parentElement?.parentElement
?.previousElementSibling?.lastElementChild as any
)?.dataset["end"]
: (selection.focusNode.parentElement?.previousElementSibling as any)
?.dataset["end"] || 0;
const reverse = parseFloat(anchorStart) >= parseFloat(focusEnd);
if (!reverse) {
anchorStart &&
focusEnd &&
setSelectedText({
start: parseFloat(anchorStart),
end: parseFloat(focusEnd),
});
} else {
const anchorEnd =
anchorNode.parentElement?.dataset["end"] ||
(
selection.anchorNode.parentElement?.parentElement
?.previousElementSibling?.lastElementChild as any
)?.dataset["end"];
const focusStart = getStartTimeFromFirstNode(
focusNode,
selection.focusOffset,
true,
);
setSelectedText({
start: parseFloat(focusStart),
end: parseFloat(anchorEnd),
});
}
}
selection && selection.empty();
};
const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return;
return (
participants.response.find(
(participant) => participant.speaker == speakerNumber,
)?.name || `Speaker ${speakerNumber}`
);
};
if (
!topicWithWords.loading &&
topicWithWords.response &&
participants.response
) {
return (
<Container
onMouseUp={onMouseUp}
max-h="100%"
width="100%"
overflow="scroll"
maxW={{ lg: "container.md" }}
{...chakraProps}
>
{topicWithWords.response.words_per_speaker?.map(
(speakerWithWords, index) => (
<Text key={index} className="mb-2 last:mb-0">
<Box
as="span"
data-speaker={speakerWithWords.speaker}
pt="1"
fontWeight="semibold"
bgColor={
selectedText == speakerWithWords.speaker ? "yellow.200" : ""
}
>
{getSpeakerName(speakerWithWords.speaker)}&nbsp;:&nbsp;
</Box>
{speakerWithWords.words.map((word, index) => (
<Box
as="span"
data-start={word.start}
data-end={word.end}
key={index}
pt="1"
bgColor={
selectedTextIsTimeSlice(selectedText) &&
selectedText.start <= word.start &&
selectedText.end >= word.end
? "yellow.200"
: ""
}
>
{word.text}
</Box>
))}
</Text>
),
)}
</Container>
);
}
if (topicWithWords.loading || participants.loading)
return <Spinner size="xl" margin="auto" />;
return null;
};
export default topicWords;

View File

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

View File

@@ -0,0 +1,154 @@
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 {
Flex,
Heading,
IconButton,
Button,
Textarea,
Spacer,
} from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
type FinalSummaryProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
onUpdate?: (newSummary) => void;
};
export default function FinalSummary(props: FinalSummaryProps) {
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState("");
const api = useApi();
const { setError } = useError();
useEffect(() => {
setEditedSummary(props.transcriptResponse?.long_summary || "");
}, [props.transcriptResponse?.long_summary]);
if (!props.topicsResponse || !props.transcriptResponse) {
return null;
}
const updateSummary = async (newSummary: string, transcriptId: string) => {
try {
const requestBody: UpdateTranscript = {
long_summary: newSummary,
};
const updatedTranscript = await api?.v1TranscriptUpdate({
transcriptId,
requestBody,
});
if (props.onUpdate) {
props.onUpdate(newSummary);
}
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
setError(err, "Failed to update long summary.");
}
};
const onEditClick = () => {
setPreEditSummary(editedSummary);
setIsEditMode(true);
};
const onDiscardClick = () => {
setEditedSummary(preEditSummary);
setIsEditMode(false);
};
const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptResponse.id);
setIsEditMode(false);
};
const handleTextAreaKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onDiscardClick();
}
if (e.key === "Enter" && e.shiftKey) {
onSaveClick();
e.preventDefault(); // prevent the default action of adding a new line
}
};
return (
<Flex
direction="column"
maxH={"100%"}
h={"100%"}
overflowY={isEditMode ? "hidden" : "auto"}
pb={4}
>
<Flex dir="row" justify="start" align="center" wrap={"wrap-reverse"}>
<Heading size={{ base: "md" }}>Summary</Heading>
{isEditMode && (
<>
<Spacer />
<Button
onClick={onDiscardClick}
colorScheme="gray"
variant={"text"}
>
Discard
</Button>
<Button onClick={onSaveClick} colorScheme="blue">
Save
</Button>
</>
)}
{!isEditMode && (
<>
<IconButton
icon={<FaPen />}
aria-label="Edit Summary"
onClick={onEditClick}
/>
<Spacer />
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</>
)}
</Flex>
{isEditMode ? (
<Textarea
value={editedSummary}
onChange={(e) => setEditedSummary(e.target.value)}
className="markdown"
onKeyDown={(e) => handleTextAreaKeyDown(e)}
h={"100%"}
resize={"none"}
mt={2}
/>
) : (
<div ref={finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown>
</div>
)}
</Flex>
);
}

View File

@@ -0,0 +1,147 @@
"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 "../topicList";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import "../../../styles/button.css";
import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
import { useRouter } from "next/navigation";
import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
type TranscriptDetails = {
params: {
transcriptId: string;
};
};
export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId;
const router = useRouter();
const statusToRedirect = ["idle", "recording", "processing"];
const transcript = useTranscript(transcriptId);
const transcriptStatus = transcript.response?.status;
const waiting = statusToRedirect.includes(transcriptStatus || "");
const topics = useTopics(transcriptId);
const waveform = useWaveform(transcriptId, waiting);
const useActiveTopic = useState<Topic | null>(null);
const mp3 = useMp3(transcriptId, waiting);
useEffect(() => {
if (waiting) {
const newUrl = "/transcripts/" + details.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
router.replace(newUrl);
// history.replaceState({}, "", newUrl);
}
}, [waiting]);
if (transcript.error || topics?.error) {
return (
<Modal
title="Transcription Not Found"
text="A trascription with this ID does not exist."
/>
);
}
if (transcript?.loading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />;
}
return (
<>
<Grid
templateColumns="1fr"
templateRows="auto minmax(0, 1fr)"
gap={4}
mt={4}
mb={4}
>
{waveform.waveform && mp3.media && topics.topics ? (
<Player
topics={topics?.topics}
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
/>
) : waveform.error ? (
<div>"error loading this recording"</div>
) : (
<Skeleton h={14} />
)}
<Grid
templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
templateRows={{
base: "auto minmax(0, 1fr) minmax(0, 1fr)",
md: "auto minmax(0, 1fr)",
}}
gap={2}
padding={4}
paddingBottom={0}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
>
<GridItem
display="flex"
flexDir="row"
alignItems={"center"}
colSpan={{ base: 1, md: 2 }}
>
<TranscriptTitle
title={transcript.response.title || "Unnamed Transcript"}
transcriptId={transcriptId}
onUpdate={(newTitle) => {
transcript.reload();
}}
/>
</GridItem>
<TopicList
topics={topics.topics || []}
useActiveTopic={useActiveTopic}
autoscroll={false}
transcriptId={transcriptId}
status={transcript.response?.status}
currentTranscriptText=""
/>
{transcript.response && topics.topics ? (
<>
<FinalSummary
transcriptResponse={transcript.response}
topicsResponse={topics.topics}
onUpdate={(newSummary) => {
transcript.reload();
}}
/>
</>
) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}>
<div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? (
<Text>Loading Transcript</Text>
) : (
<Text>
There was an error generating the final summary, please come
back later
</Text>
)}
</div>
</Flex>
)}
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../../topicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import Player from "../../player";
import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription";
type TranscriptDetails = {
params: {
transcriptId: string;
};
};
const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null);
const webSockets = useWebSockets(details.params.transcriptId);
let mp3 = useMp3(details.params.transcriptId, true);
const router = useRouter();
const [status, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle",
);
useEffect(() => {
if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
setTranscriptStarted(true);
}, [webSockets.transcriptTextLive]);
useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER
const newStatus =
webSockets.status.value || transcript.response?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status.value, transcript.response?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
}, [webSockets.waveform, webSockets.duration]);
useEffect(() => {
lockWakeState();
return () => {
releaseWakeState();
};
}, []);
return (
<Grid
templateColumns="1fr"
templateRows="auto minmax(0, 1fr) "
gap={4}
mt={4}
mb={4}
>
{status == "processing" ? (
<WaveformLoading />
) : (
// todo: only start recording animation when you get "recorded" status
<Recorder transcriptId={details.params.transcriptId} status={status} />
)}
<VStack
align={"left"}
w="full"
h="full"
mb={4}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
p="4"
>
<Heading size={"lg"}>
{status === "processing" ? "Processing meeting" : "Record meeting"}
</Heading>
<Flex direction={{ base: "column-reverse", md: "row" }} h={"full"}>
<Box w={{ md: "50%" }} h={{ base: "80%", md: "full" }}>
<TopicList
topics={webSockets.topics}
useActiveTopic={useActiveTopic}
autoscroll={true}
transcriptId={details.params.transcriptId}
status={status}
currentTranscriptText={webSockets.accumulatedText}
/>
</Box>
<Box w={{ md: "50%" }} h={{ base: "20%", md: "full" }}>
{!transcriptStarted ? (
<Box textAlign={"center"} textColor="gray">
<Text>
Live transcript will appear here shortly after you'll start
recording.
</Text>
</Box>
) : (
status === "recording" && (
<LiveTrancription
text={webSockets.transcriptTextLive}
translateText={webSockets.translateText}
/>
)
)}
</Box>
</Flex>
</VStack>
</Grid>
);
};
export default TranscriptRecord;

View File

@@ -0,0 +1,159 @@
import React, { useContext, useState, useEffect } from "react";
import SelectSearch from "react-select-search";
import { getZulipMessage, sendZulipMessage } from "../../../lib/zulip";
import { GetTranscript, GetTranscriptTopic } from "../../../api";
import "react-select-search/style.css";
import { DomainContext } from "../../../domainContext";
type ShareModal = {
show: boolean;
setShow: (show: boolean) => void;
transcript: GetTranscript | null;
topics: GetTranscriptTopic[] | null;
};
interface Stream {
id: number;
name: string;
topics: string[];
}
interface SelectSearchOption {
name: string;
value: string;
}
const ShareModal = (props: ShareModal) => {
const [stream, setStream] = useState<string | undefined>(undefined);
const [topic, setTopic] = useState<string | undefined>(undefined);
const [includeTopics, setIncludeTopics] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]);
const { zulip_streams } = useContext(DomainContext);
useEffect(() => {
fetch(zulip_streams + "/streams.json")
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
})
.then((data) => {
data = data.sort((a: Stream, b: Stream) =>
a.name.localeCompare(b.name),
);
setStreams(data);
setIsLoading(false);
// data now contains the JavaScript object decoded from JSON
})
.catch((error) => {
console.error("There was a problem with your fetch operation:", error);
});
}, []);
const handleSendToZulip = () => {
if (!props.transcript) return;
const msg = getZulipMessage(props.transcript, props.topics, includeTopics);
if (stream && topic) sendZulipMessage(stream, topic, msg);
};
if (props.show && isLoading) {
return <div>Loading...</div>;
}
let streamOptions: SelectSearchOption[] = [];
if (streams) {
streams.forEach((stream) => {
const value = stream.name;
streamOptions.push({ name: value, value: value });
});
}
return (
<div className="absolute">
{props.show && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<h3 className="font-bold text-xl">Send to Zulip</h3>
{/* Checkbox for 'Include Topics' */}
<div className="mt-4 text-left ml-5">
<label className="flex items-center">
<input
type="checkbox"
className="form-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
checked={includeTopics}
onChange={(e) => setIncludeTopics(e.target.checked)}
/>
<span className="ml-2">Include topics</span>
</label>
</div>
<div className="flex items-center mt-4">
<span className="mr-2">#</span>
<SelectSearch
search={true}
options={streamOptions}
value={stream}
onChange={(val) => {
setTopic(undefined);
setStream(val.toString());
}}
placeholder="Pick a stream"
/>
</div>
{stream && (
<>
<div className="flex items-center mt-4">
<span className="mr-2 invisible">#</span>
<SelectSearch
search={true}
options={
streams
.find((s) => s.name == stream)
?.topics.sort((a: string, b: string) =>
a.localeCompare(b),
)
.map((t) => ({ name: t, value: t })) || []
}
value={topic}
onChange={(val) => setTopic(val.toString())}
placeholder="Pick a topic"
/>
</div>
</>
)}
<button
className={`bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded py-2 px-4 mr-3 ${
!stream || !topic ? "opacity-50 cursor-not-allowed" : ""
}`}
disabled={!stream || !topic}
onClick={() => {
handleSendToZulip();
props.setShow(false);
}}
>
Send to Zulip
</button>
<button
className="bg-red-500 hover:bg-red-700 focus-visible:bg-red-700 text-white rounded py-2 px-4 mt-4"
onClick={() => props.setShow(false)}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ShareModal;

View File

@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton";
type TranscriptUpload = {
params: {
transcriptId: string;
};
};
const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscript(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId);
let mp3 = useMp3(details.params.transcriptId, true);
const router = useRouter();
const [status, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle",
);
useEffect(() => {
if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
setTranscriptStarted(true);
}, [webSockets.transcriptTextLive]);
useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER
const newStatus =
webSockets.status.value || transcript.response?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status.value, transcript.response?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
}, [webSockets.waveform, webSockets.duration]);
useEffect(() => {
lockWakeState();
return () => {
releaseWakeState();
};
}, []);
return (
<>
<VStack
align={"left"}
w="full"
h="full"
mb={4}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
p="4"
>
<Heading size={"lg"}>Upload meeting</Heading>
<Center h={"full"} w="full">
<VStack spacing={10}>
{status && status == "idle" && (
<>
<Text>
Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm
</Text>
<FileUploadButton transcriptId={details.params.transcriptId} />
</>
)}
{status && status == "uploaded" && (
<Text>File is uploaded, processing...</Text>
)}
{(status == "recording" || status == "processing") && (
<>
<Heading size={"lg"}>Processing your recording...</Heading>
<Text>
You can safely return to the library while your file is being
processed.
</Text>
<Button
colorScheme="blue"
onClick={() => {
router.push("/browse");
}}
>
Browse
</Button>
</>
)}
</VStack>
</Center>
</VStack>
</>
);
};
export default TranscriptUpload;

View File

@@ -0,0 +1,28 @@
import React from "react";
import Dropdown, { Option } from "react-dropdown";
import "react-dropdown/style.css";
const AudioInputsDropdown: React.FC<{
audioDevices: Option[];
disabled: boolean;
hide: () => void;
deviceId: string;
setDeviceId: React.Dispatch<React.SetStateAction<string | null>>;
}> = (props) => {
const handleDropdownChange = (option: Option) => {
props.setDeviceId(option.value);
props.hide();
};
return (
<Dropdown
options={props.audioDevices}
onChange={handleDropdownChange}
value={props.deviceId}
className="flex-grow w-full"
disabled={props.disabled}
/>
);
};
export default AudioInputsDropdown;

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import { CreateTranscript, GetTranscript } from "../../api";
import useApi from "../../lib/useApi";
type UseCreateTranscript = {
transcript: GetTranscript | null;
loading: boolean;
error: Error | null;
create: (transcriptCreationDetails: CreateTranscript) => 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 create = (transcriptCreationDetails: CreateTranscript) => {
if (loading || !api) 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);
});
};
return { transcript, loading, error, create };
};
export default useCreateTranscript;

View File

@@ -0,0 +1,13 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLinkSlash } from "@fortawesome/free-solid-svg-icons";
export default function DisconnectedIndicator() {
return (
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
<div className="text-white text-2xl">
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
Disconnected
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import React, { useState } from "react";
import useApi from "../../lib/useApi";
import { Button, CircularProgress } from "@chakra-ui/react";
type FileUploadButton = {
transcriptId: string;
};
export default function FileUploadButton(props: FileUploadButton) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const api = useApi();
const [progress, setProgress] = useState(0);
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const maxChunkSize = 50 * 1024 * 1024; // 50 MB
const totalChunks = Math.ceil(file.size / maxChunkSize);
let chunkNumber = 0;
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;
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,
});
uploadedSize += chunkSize;
chunkNumber++;
start = end;
uploadNextChunk();
};
uploadNextChunk();
}
};
return (
<>
<Button
onClick={triggerFileUpload}
colorScheme="blue"
mr={2}
isDisabled={progress > 0}
>
{progress > 0 && progress < 100 ? (
<>
Uploading...&nbsp;
<CircularProgress size="20px" value={progress} />
</>
) : (
<>Select File</>
)}
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileUpload}
/>
</>
);
}

View File

@@ -0,0 +1,23 @@
type LiveTranscriptionProps = {
text: string;
translateText: string;
};
export default function LiveTrancription(props: LiveTranscriptionProps) {
return (
<div className="text-center p-4">
<p
className={`text-lg md:text-xl lg:text-2xl font-bold ${
props.translateText ? "line-clamp-2 lg:line-clamp-5" : "line-clamp-4"
}`}
>
{props.text}
</p>
{props.translateText && (
<p className="text-base md:text-lg lg:text-xl font-bold line-clamp-2 lg:line-clamp-4 mt-4">
{props.translateText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
[
{
"id": "27c07e49-d7a3-4b86-905c-f1a047366f91",
"title": "Issue one",
"summary": "The team discusses the first issue in the list",
"timestamp": 0.0,
"transcript": "",
"duration": 33,
"segments": [
{
"text": "Let's start with issue one, Alice you've been working on that, can you give an update ?",
"start": 0.0,
"speaker": 0
},
{
"text": "Yes, I've run into an issue with the task system but Bob helped me out and I have a POC ready, should I present it now ?",
"start": 0.38,
"speaker": 1
},
{
"text": "Yeah, I had to modify the task system because it didn't account for incoming blobs",
"start": 4.5,
"speaker": 2
},
{
"text": "Cool, yeah lets see it",
"start": 5.96,
"speaker": 0
}
]
}
]

View File

@@ -0,0 +1,15 @@
type ModalProps = {
title: string;
text: string;
};
export default function Modal(props: ModalProps) {
return (
<>
<div className="w-full flex flex-col items-center justify-center bg-white px-6 py-8 mt-8 rounded-xl">
<h1 className="text-2xl font-bold text-blue-500">{props.title}</h1>
<p className="text-gray-500 text-center mt-5">{props.text}</p>
</div>
</>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import React, { useEffect, useState } from "react";
import useAudioDevice from "../useAudioDevice";
import "react-select-search/style.css";
import "../../../styles/button.css";
import "../../../styles/form.scss";
import About from "../../../(aboutAndPrivacy)/about";
import Privacy from "../../../(aboutAndPrivacy)/privacy";
import { useRouter } from "next/navigation";
import useCreateTranscript from "../createTranscript";
import SelectSearch from "react-select-search";
import { supportedLanguages } from "../../../supportedLanguages";
import { useFiefIsAuthenticated } from "@fief/fief/nextjs/react";
import { featureEnabled } from "../../../domainContext";
import { Button, Text } from "@chakra-ui/react";
const TranscriptCreate = () => {
const router = useRouter();
const isAuthenticated = useFiefIsAuthenticated();
const requireLogin = featureEnabled("requireLogin");
const [name, setName] = useState<string>("");
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
};
const [targetLanguage, setTargetLanguage] = useState<string>();
const onLanguageChange = (newval) => {
(!newval || typeof newval === "string") && setTargetLanguage(newval);
};
const createTranscript = useCreateTranscript();
const [loadingRecord, setLoadingRecord] = useState(false);
const [loadingUpload, setLoadingUpload] = useState(false);
const send = () => {
if (loadingRecord || createTranscript.loading || permissionDenied) return;
setLoadingRecord(true);
createTranscript.create({ name, target_language: targetLanguage });
};
const uploadFile = () => {
if (loadingUpload || createTranscript.loading || permissionDenied) return;
setLoadingUpload(true);
createTranscript.create({ name, target_language: targetLanguage });
};
useEffect(() => {
let action = "record";
if (loadingUpload) action = "upload";
createTranscript.transcript &&
router.push(`/transcripts/${createTranscript.transcript.id}/${action}`);
}, [createTranscript.transcript]);
useEffect(() => {
if (createTranscript.error) setLoadingRecord(false);
}, [createTranscript.error]);
const { loading, permissionOk, permissionDenied, requestPermission } =
useAudioDevice();
return (
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full overflow-y-scroll">
<div className="lg:grid lg:grid-cols-2 lg:grid-rows-1 lg:gap-4 lg:h-full h-auto flex flex-col">
<section className="flex flex-col w-full lg:h-full items-center justify-evenly p-4 md:px-6 md:py-8">
<div className="flex flex-col max-w-xl items-center justify-center">
<h1 className="text-2xl font-bold mb-2">Welcome to Reflector</h1>
<p>
Reflector is a transcription and summarization pipeline that
transforms audio into knowledge.
<span className="hidden md:block">
The output is meeting minutes and topic summaries enabling
topic-specific analyses stored in your systems of record. This
is accomplished on your infrastructure without 3rd parties
keeping your data private, secure, and organized.
</span>
</p>
<About buttonText="Learn more" />
<p className="mt-6">
In order to use Reflector, we kindly request permission to access
your microphone during meetings and events.
</p>
{featureEnabled("privacy") && (
<Privacy buttonText="Privacy policy" />
)}
</div>
</section>
<section className="flex flex-col justify-center items-center w-full h-full">
{requireLogin && !isAuthenticated ? (
<button
className="mt-4 bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white font-bold py-2 px-4 rounded"
onClick={() => router.push("/login")}
>
Log in
</button>
) : (
<div className="rounded-xl md:bg-blue-200 md:w-96 p-4 lg:p-6 flex flex-col mb-4 md:mb-10">
<h2 className="text-2xl font-bold mt-2 mb-2">Try Reflector</h2>
<label className="mb-3">
<p>Recording name</p>
<div className="select-search-container">
<input
className="select-search-input"
type="text"
onChange={nameChange}
placeholder="Optional"
/>
</div>
</label>
<label className="mb-3">
<p>Do you want to enable live translation?</p>
<SelectSearch
search
options={supportedLanguages}
value={targetLanguage}
onChange={onLanguageChange}
placeholder="Choose your language"
/>
</label>
{loading ? (
<p className="">Checking permissions...</p>
) : permissionOk ? (
<p className=""> Microphone permission granted </p>
) : permissionDenied ? (
<p className="">
Permission to use your microphone was denied, please change
the permission setting in your browser and refresh this page.
</p>
) : (
<Button
colorScheme="blue"
onClick={requestPermission}
disabled={permissionDenied}
>
Request Microphone Permission
</Button>
)}
<Button
colorScheme="blue"
onClick={send}
isDisabled={!permissionOk || loadingRecord || loadingUpload}
mt={2}
>
{loadingRecord ? "Loading..." : "Record Meeting"}
</Button>
<Text align="center" m="2">
OR
</Text>
<Button
colorScheme="blue"
onClick={uploadFile}
isDisabled={loadingRecord || loadingUpload}
>
{loadingUpload ? "Loading..." : "Upload File"}
</Button>
</div>
)}
</section>
</div>
</div>
);
};
export default TranscriptCreate;

View File

@@ -0,0 +1,194 @@
import React, { useRef, useEffect, useState } from "react";
import WaveSurfer from "wavesurfer.js";
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 { waveSurferStyles } from "../../styles/recorder";
import { Box, Flex, IconButton } from "@chakra-ui/react";
import PlayIcon from "../../styles/icons/play";
import PauseIcon from "../../styles/icons/pause";
type PlayerProps = {
topics: Topic[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
waveform: AudioWaveform;
media: HTMLMediaElement;
mediaDuration: number;
};
export default function Player(props: PlayerProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [waveRegions, setWaveRegions] = useState<RegionsPlugin | null>(null);
const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics);
const [firstRender, setFirstRender] = useState<boolean>(true);
const keyHandler = (e) => {
if (e.key == " ") {
wavesurfer?.playPause();
}
};
useEffect(() => {
document.addEventListener("keyup", keyHandler);
return () => {
document.removeEventListener("keyup", keyHandler);
};
});
// Waveform setup
useEffect(() => {
if (waveformRef.current) {
const _wavesurfer = WaveSurfer.create({
container: waveformRef.current,
peaks: [props.waveform.data],
height: "auto",
duration: Math.floor(props.mediaDuration / 1000),
media: props.media,
...waveSurferStyles.playerSettings,
});
// styling
const wsWrapper = _wavesurfer.getWrapper();
wsWrapper.style.cursor = waveSurferStyles.playerStyle.cursor;
wsWrapper.style.backgroundColor =
waveSurferStyles.playerStyle.backgroundColor;
wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius;
_wavesurfer.on("play", () => {
setIsPlaying(true);
});
_wavesurfer.on("pause", () => {
setIsPlaying(false);
});
_wavesurfer.on("timeupdate", setCurrentTime);
setWaveRegions(_wavesurfer.registerPlugin(RegionsPlugin.create()));
_wavesurfer.toggleInteraction(true);
setWavesurfer(_wavesurfer);
return () => {
_wavesurfer.destroy();
setIsPlaying(false);
setCurrentTime(0);
};
}
}, []);
useEffect(() => {
if (!wavesurfer) return;
if (!props.media) return;
wavesurfer.setMediaElement(props.media);
}, [props.media, wavesurfer]);
useEffect(() => {
if (!waveRegions) return;
topicsRef.current = props.topics;
if (firstRender) {
setFirstRender(false);
// wait for the waveform to render, if you don't markers will be stacked on top of each other
// I tried to listen for the waveform to be ready but it didn't work
setTimeout(() => {
renderMarkers();
}, 300);
} else {
renderMarkers();
}
}, [props.topics, waveRegions]);
const renderMarkers = () => {
if (!waveRegions) return;
waveRegions.clearRegions();
for (let topic of topicsRef.current) {
const content = document.createElement("div");
content.setAttribute("style", waveSurferStyles.marker);
content.onmouseover = (e) => {
content.style.backgroundColor =
waveSurferStyles.markerHover.backgroundColor;
content.style.width = "300px";
if (content.parentElement) {
content.parentElement.style.zIndex = "999";
}
};
content.onmouseout = () => {
content.setAttribute("style", waveSurferStyles.marker);
if (content.parentElement) {
content.parentElement.style.zIndex = "0";
}
};
content.textContent = topic.title;
const region = waveRegions.addRegion({
start: topic.timestamp,
content,
drag: false,
resize: false,
top: 0,
});
region.on("click", (e) => {
e.stopPropagation();
setActiveTopic(topic);
wavesurfer?.setTime(region.start);
});
}
};
useEffect(() => {
if (activeTopic) {
wavesurfer?.setTime(activeTopic.timestamp);
}
}, [activeTopic]);
const handlePlayClick = () => {
wavesurfer?.playPause();
};
const timeLabel = () => {
if (props.mediaDuration && Math.floor(props.mediaDuration / 1000) > 0)
return `${formatTime(currentTime)}/${formatTimeMs(props.mediaDuration)}`;
return "";
};
return (
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isPlaying ? "Pause" : "Play"}
icon={isPlaying ? <PauseIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
mr={2}
id="play-btn"
onClick={handlePlayClick}
/>
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>
<Box
zIndex={50}
backgroundColor="rgba(255, 255, 255, 0.5)"
fontSize={"sm"}
shadow={"0px 0px 4px 0px white"}
position={"absolute"}
right={0}
bottom={0}
>
{timeLabel()}
</Box>
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,322 @@
import React, { useRef, useEffect, useState } from "react";
import WaveSurfer from "wavesurfer.js";
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,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
} from "@chakra-ui/react";
import StopRecordIcon from "../../styles/icons/stopRecord";
import PlayIcon from "../../styles/icons/play";
import { LuScreenShare } from "react-icons/lu";
import { FaMicrophone } from "react-icons/fa";
type RecorderProps = {
transcriptId: string;
status: string;
};
export default function Recorder(props: RecorderProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const [record, setRecord] = useState<RecordPlugin | null>(null);
const [isRecording, setIsRecording] = useState<boolean>(false);
const [duration, setDuration] = useState<number>(0);
const [deviceId, setDeviceId] = useState<string | null>(null);
const { setError } = useError();
const [stream, setStream] = useState<MediaStream | null>(null);
// Time tracking, iirc it was drifting without this. to be tested again.
const [startTime, setStartTime] = useState(0);
const [currentTime, setCurrentTime] = useState<number>(0);
const [timeInterval, setTimeInterval] = useState<number | null>(null);
const webRTC = useWebRTC(stream, props.transcriptId);
const { audioDevices, getAudioStream } = useAudioDevice();
// Function used to setup keyboard shortcuts for the streamdeck
const setupProjectorKeys = (): (() => void) => {
if (!record) return () => {};
const handleKeyPress = (event: KeyboardEvent) => {
switch (event.key) {
case "~":
location.href = "";
break;
case ",":
location.href = "/transcripts/new";
break;
case "!":
if (record.isRecording()) return;
handleRecClick();
break;
case "@":
if (!record.isRecording()) return;
handleRecClick();
break;
case "(":
location.href = "/login";
break;
case ")":
location.href = "/logout";
break;
default:
break;
}
};
document.addEventListener("keydown", handleKeyPress);
// Return the cleanup function
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
};
// Setup Shortcuts
useEffect(() => {
if (!record) return;
return setupProjectorKeys();
}, [record, deviceId]);
// Waveform setup
useEffect(() => {
if (waveformRef.current) {
const _wavesurfer = WaveSurfer.create({
container: waveformRef.current,
hideScrollbar: true,
autoCenter: true,
barWidth: 2,
height: "auto",
...waveSurferStyles.player,
});
const _wshack: any = _wavesurfer;
_wshack.renderer.renderSingleCanvas = () => {};
// styling
const wsWrapper = _wavesurfer.getWrapper();
wsWrapper.style.cursor = waveSurferStyles.playerStyle.cursor;
wsWrapper.style.backgroundColor =
waveSurferStyles.playerStyle.backgroundColor;
wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius;
_wavesurfer.on("timeupdate", setCurrentTime);
setRecord(_wavesurfer.registerPlugin(RecordPlugin.create()));
return () => {
_wavesurfer.destroy();
setIsRecording(false);
setCurrentTime(0);
};
}
}, []);
useEffect(() => {
if (isRecording) {
const interval = window.setInterval(() => {
setCurrentTime(Date.now() - startTime);
}, 1000);
setTimeInterval(interval);
return () => clearInterval(interval);
} else {
clearInterval(timeInterval as number);
setCurrentTime((prev) => {
setDuration(prev);
return 0;
});
}
}, [isRecording]);
const handleRecClick = async () => {
if (!record) return console.log("no record");
if (record.isRecording()) {
setStream(null);
webRTC?.send(JSON.stringify({ cmd: "STOP" }));
record.stopRecording();
if (screenMediaStream) {
screenMediaStream.getTracks().forEach((t) => t.stop());
}
setIsRecording(false);
setScreenMediaStream(null);
setDestinationStream(null);
} else {
const stream = await getMicrophoneStream();
setStartTime(Date.now());
setStream(stream);
if (stream) {
await record.startRecording(stream);
setIsRecording(true);
}
}
};
const [screenMediaStream, setScreenMediaStream] =
useState<MediaStream | null>(null);
const handleRecordTabClick = async () => {
if (!record) return console.log("no record");
const stream: MediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100,
},
});
if (stream.getAudioTracks().length == 0) {
setError(new Error("No audio track found in screen recording."));
return;
}
setScreenMediaStream(stream);
};
const [destinationStream, setDestinationStream] =
useState<MediaStream | null>(null);
const startTabRecording = async () => {
if (!screenMediaStream) return;
if (!record) return;
if (destinationStream !== null) return console.log("already recording");
// connect mic audio (microphone)
const micStream = await getMicrophoneStream();
if (!micStream) {
console.log("no microphone audio");
return;
}
// Create MediaStreamSource nodes for the microphone and tab
const audioContext = new AudioContext();
const micSource = audioContext.createMediaStreamSource(micStream);
const tabSource = audioContext.createMediaStreamSource(screenMediaStream);
// Merge channels
// XXX If the length is not the same, we do not receive audio in WebRTC.
// So for now, merge the channels to have only one stereo source
const channelMerger = audioContext.createChannelMerger(1);
micSource.connect(channelMerger, 0, 0);
tabSource.connect(channelMerger, 0, 0);
// Create a MediaStreamDestination node
const destination = audioContext.createMediaStreamDestination();
channelMerger.connect(destination);
// Use the destination's stream for the WebRTC connection
setDestinationStream(destination.stream);
};
useEffect(() => {
if (!record) return;
if (!destinationStream) return;
setStream(destinationStream);
if (destinationStream) {
record.startRecording(destinationStream);
setIsRecording(true);
}
}, [record, destinationStream]);
useEffect(() => {
startTabRecording();
}, [record, screenMediaStream]);
const timeLabel = () => {
if (isRecording) return formatTimeMs(currentTime);
if (duration) return `${formatTimeMs(currentTime)}/${formatTime(duration)}`;
return "";
};
const getMicrophoneStream = async () => {
return deviceId && getAudioStream ? await getAudioStream(deviceId) : null;
};
useEffect(() => {
if (audioDevices && audioDevices.length > 0) {
setDeviceId(audioDevices[0].value);
}
}, [audioDevices]);
return (
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isRecording ? "Stop" : "Record"}
icon={isRecording ? <StopRecordIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
mr={2}
onClick={handleRecClick}
/>
{!isRecording && (window as any).chrome && (
<IconButton
aria-label={"Record Tab"}
icon={<LuScreenShare />}
variant={"ghost"}
colorScheme={"blue"}
disabled={isRecording}
mr={2}
onClick={handleRecordTabClick}
/>
)}
{audioDevices && audioDevices?.length > 0 && deviceId && !isRecording && (
<Menu>
<MenuButton
as={IconButton}
aria-label={"Switch microphone"}
icon={<FaMicrophone />}
variant={"ghost"}
disabled={isRecording}
colorScheme={"blue"}
mr={2}
/>
<MenuList>
<MenuOptionGroup defaultValue={audioDevices[0].value} type="radio">
{audioDevices.map((device) => (
<MenuItemOption
key={device.value}
value={device.value}
onClick={() => setDeviceId(device.value)}
>
{device.label}
</MenuItemOption>
))}
</MenuOptionGroup>
</MenuList>
</Menu>
)}
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>
<Box
zIndex={50}
backgroundColor="rgba(255, 255, 255, 0.5)"
fontSize={"sm"}
shadow={"0px 0px 4px 0px white"}
position={"absolute"}
right={0}
bottom={0}
>
{timeLabel()}
</Box>
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,23 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown } from "@fortawesome/free-solid-svg-icons";
type ScrollToBottomProps = {
visible: boolean;
handleScrollBottom: () => void;
};
export default function ScrollToBottom(props: ScrollToBottomProps) {
return (
<div
className={`absolute bottom-0 right-[0.15rem] md:right-[0.65rem] ${
props.visible ? "flex" : "hidden"
} text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 text-blue-400`}
onClick={() => {
props.handleScrollBottom();
return false;
}}
>
<FontAwesomeIcon icon={faArrowDown} className="animate-bounce" />
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { featureEnabled } from "../../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import { GetTranscript, GetTranscriptTopic, UpdateTranscript } from "../../api";
import {
Box,
Flex,
IconButton,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
} from "@chakra-ui/react";
import { FaShare } from "react-icons/fa";
import { useFiefUserinfo } from "@fief/fief/build/esm/nextjs/react";
import useApi from "../../lib/useApi";
import { Select } from "chakra-react-select";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
type ShareAndPrivacyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
type ShareOption = { value: ShareMode; label: string };
const shareOptions = [
{ label: "Private", value: toShareMode("private") },
{ label: "Secure", value: toShareMode("semi-private") },
{ label: "Public", value: toShareMode("public") },
];
export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [showModal, setShowModal] = useState(false);
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>(
shareOptions.find(
(option) => option.value === props.transcriptResponse.share_mode,
) || shareOptions[0],
);
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const api = useApi();
const updateShareMode = async (selectedShareMode: any) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode.value),
};
const updatedTranscript = await api.v1TranscriptUpdate({
transcriptId: props.transcriptResponse.id,
requestBody,
});
setShareMode(
shareOptions.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptions[0],
);
setShareLoading(false);
};
const userId = useFiefUserinfo()?.sub;
useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
}, [userId, props.transcriptResponse.user_id]);
return (
<>
<IconButton
icon={<FaShare />}
onClick={() => setShowModal(true)}
aria-label="Share"
/>
<Modal
isOpen={!!showModal}
onClose={() => setShowModal(false)}
size={"xl"}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Share</ModalHeader>
<ModalBody>
{requireLogin && (
<Box mb={4}>
<Text size="sm" mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text size="sm" mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
{isOwner && api && (
<Select
options={
[
{ value: "private", label: "Private" },
{ label: "Secure", value: "semi-private" },
{ label: "Public", value: "public" },
] as any
}
value={shareMode}
onChange={updateShareMode}
isLoading={shareLoading}
/>
)}
</Box>
)}
<Text size="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</Flex>
<ShareLink transcriptId={props.transcriptResponse.id} />
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
export default function ShareCopy({
finalSummaryRef,
transcriptResponse,
topicsResponse,
...boxProps
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => {
let text_to_copy = finalSummaryRef.current?.innerText;
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000);
});
};
const onCopyTranscriptClick = () => {
let text_to_copy =
topicsResponse
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")
.trim() || "";
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedTranscript(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedTranscript(false), 2000);
});
};
return (
<Box {...boxProps}>
<Button
onClick={onCopyTranscriptClick}
colorScheme="blue"
size={"sm"}
mr={2}
>
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</Button>
<Button onClick={onCopySummaryClick} colorScheme="blue" size={"sm"}>
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</Button>
</Box>
);
}

View File

@@ -0,0 +1,74 @@
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";
type ShareLinkProps = {
transcriptId: string;
};
const ShareLink = (props: ShareLinkProps) => {
const [isCopied, setIsCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [currentUrl, setCurrentUrl] = useState<string>("");
const requireLogin = featureEnabled("requireLogin");
const privacyEnabled = featureEnabled("privacy");
useEffect(() => {
setCurrentUrl(window.location.href);
}, []);
const handleCopyClick = () => {
if (inputRef.current) {
let text_to_copy = inputRef.current.value;
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopied(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopied(false), 2000);
});
}
};
return (
<>
{!requireLogin && (
<>
{privacyEnabled ? (
<Text>
Share this link to grant others access to this page. The link
includes the full audio recording and is valid for the next 7
days.
</Text>
) : (
<Text>
Share this link to allow others to view this page and listen to
the full audio recording.
</Text>
)}
</>
)}
<Flex align={"center"}>
<QRCode
value={`${location.origin}/transcripts/${props.transcriptId}`}
level="L"
size={98}
/>
<Input
type="text"
readOnly
value={currentUrl}
ref={inputRef}
onChange={() => {}}
mx="2"
/>
<Button onClick={handleCopyClick} colorScheme="blue">
{isCopied ? "Copied!" : "Copy"}
</Button>
</Flex>
</>
);
};
export default ShareLink;

View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import { featureEnabled } from "../../domainContext";
import ShareModal from "./[transcriptId]/shareModal";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { BoxProps, Button } from "@chakra-ui/react";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
disabled: boolean;
};
export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const [showModal, setShowModal] = useState(false);
if (!featureEnabled("sendToZulip")) return null;
return (
<>
<Button
colorScheme="blue"
size={"sm"}
isDisabled={props.disabled}
onClick={() => setShowModal(true)}
>
Send to Zulip
</Button>
<ShareModal
transcript={props.transcriptResponse}
topics={props.topicsResponse}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
</>
);
}

View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect } from "react";
import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Text,
} from "@chakra-ui/react";
import { featureEnabled } from "../../domainContext";
type TopicListProps = {
topics: Topic[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
autoscroll: boolean;
transcriptId: string;
status: string;
currentTranscriptText: any;
};
export function TopicList({
topics,
useActiveTopic,
autoscroll,
transcriptId,
status,
currentTranscriptText,
}: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
const participants = useParticipants(transcriptId);
const scrollToTopic = () => {
const topicDiv = document.getElementById(
`accordion-button-topic-${activeTopic?.id}`,
);
setTimeout(() => {
topicDiv?.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest",
});
}, 200);
};
useEffect(() => {
if (activeTopic) scrollToTopic();
}, [activeTopic]);
// scroll top is not rounded, heights are, so exact match won't work.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const toggleScroll = (element) => {
const bottom =
Math.abs(
element.scrollHeight - element.clientHeight - element.scrollTop,
) < 2 || element.scrollHeight == element.clientHeight;
if (!bottom && autoscrollEnabled) {
setAutoscrollEnabled(false);
} else if (bottom && !autoscrollEnabled) {
setAutoscrollEnabled(true);
}
};
const handleScroll = (e) => {
toggleScroll(e.target);
};
useEffect(() => {
if (autoscroll) {
const topicsDiv = document.getElementById("scroll-div");
topicsDiv && toggleScroll(topicsDiv);
}
}, [activeTopic, autoscroll]);
useEffect(() => {
if (autoscroll && autoscrollEnabled) scrollToBottom();
}, [topics.length, currentTranscriptText]);
const scrollToBottom = () => {
const topicsDiv = document.getElementById("scroll-div");
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return;
return (
participants.response.find(
(participant) => participant.speaker == speakerNumber,
)?.name || `Speaker ${speakerNumber}`
);
};
const requireLogin = featureEnabled("requireLogin");
useEffect(() => {
setActiveTopic(topics[topics.length - 1]);
}, [topics]);
return (
<Flex
position={"relative"}
w={"100%"}
h={"95%"}
flexDirection={"column"}
justify={"center"}
align={"center"}
flexShrink={0}
>
{autoscroll && (
<ScrollToBottom
visible={!autoscrollEnabled}
handleScrollBottom={scrollToBottom}
/>
)}
<Box
id="scroll-div"
overflowY={"auto"}
h={"100%"}
onScroll={handleScroll}
width="full"
padding={2}
>
{topics.length > 0 && (
<Accordion
index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
variant="custom"
allowToggle
>
{topics.map((topic, index) => (
<AccordionItem
key={index}
background={{
base: "light",
hover: "gray.100",
focus: "gray.100",
}}
id={`topic-${topic.id}`}
>
<Flex dir="row" letterSpacing={".2"}>
<AccordionButton
onClick={() => {
setActiveTopic(
activeTopic?.id == topic.id ? null : topic,
);
}}
>
<AccordionIcon />
<Box as="span" textAlign="left" ml="1">
{topic.title}{" "}
<Text
as="span"
color="gray.500"
fontSize="sm"
fontWeight="bold"
>
&nbsp;[{formatTime(topic.timestamp)}]&nbsp;-&nbsp;[
{formatTime(topic.timestamp + (topic.duration || 0))}]
</Text>
</Box>
</AccordionButton>
</Flex>
<AccordionPanel>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<Text
key={index}
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight={"1.3"}
>
<Text
as="span"
color={"gray.500"}
fontFamily={"monospace"}
fontSize={"sm"}
>
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight={"bold"}
fontSize={"sm"}
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{getSpeakerName(segment.speaker)}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
))}
</>
) : (
<>{topic.transcript}</>
)}
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)}
{status == "recording" && (
<Box textAlign={"center"}>
<Text>{currentTranscriptText}</Text>
</Box>
)}
{(status == "recording" || status == "idle") &&
currentTranscriptText.length == 0 &&
topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>
Full discussion transcript will appear here after you start
recording.
</Text>
<Text>
It may take up to 5 minutes of conversation to first appear.
</Text>
</Box>
)}
{status == "processing" && (
<Box textAlign={"center"} textColor="gray">
<Text>We are processing the recording, please wait.</Text>
{!requireLogin && (
<span>
Please do not navigate away from the page during this time.
</span>
)}
</Box>
)}
{status == "ended" && topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>Recording has ended without topics being found.</Text>
</Box>
)}
{status == "error" && (
<Box textAlign={"center"} textColor="gray">
<Text>There was an error processing your recording</Text>
</Box>
)}
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi";
import { Heading, IconButton, Input } from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
type TranscriptTitle = {
title: string;
transcriptId: string;
onUpdate?: (newTitle: string) => void;
};
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 updateTitle = async (newTitle: string, transcriptId: string) => {
if (!api) return;
try {
const requestBody: UpdateTranscript = {
title: newTitle,
};
const updatedTranscript = await api?.v1TranscriptUpdate({
transcriptId,
requestBody,
});
if (props.onUpdate) {
props.onUpdate(newTitle);
}
console.log("Updated transcript:", updatedTranscript);
} catch (err) {
console.error("Failed to update transcript:", err);
}
};
const handleTitleClick = () => {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
// Use prompt
const newTitle = prompt("Please enter the new title:", displayedTitle);
if (newTitle !== null) {
setDisplayedTitle(newTitle);
updateTitle(newTitle, props.transcriptId);
}
} else {
setPreEditTitle(displayedTitle);
setIsEditing(true);
}
};
const handleBlur = () => {
if (displayedTitle !== preEditTitle) {
updateTitle(displayedTitle, props.transcriptId);
}
setIsEditing(false);
};
const handleChange = (e) => {
setDisplayedTitle(e.target.value);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
updateTitle(displayedTitle, props.transcriptId);
setIsEditing(false);
} else if (e.key === "Escape") {
setDisplayedTitle(preEditTitle);
setIsEditing(false);
}
};
return (
<>
{isEditing ? (
<Input
type="text"
value={displayedTitle}
onChange={handleChange}
onKeyDown={handleKeyDown}
autoFocus
onBlur={handleBlur}
size={"lg"}
fontSize={"xl"}
fontWeight={"bold"}
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 w-full border-none bg-transparent overflow-hidden h-[fit-content]"
/>
) : (
<>
<Heading
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer"
onClick={handleTitleClick}
cursor={"pointer"}
size={"lg"}
noOfLines={1}
>
{displayedTitle}
</Heading>
<IconButton
icon={<FaPen />}
aria-label="Edit Transcript Title"
onClick={handleTitleClick}
fontSize={"15px"}
/>
</>
)}
</>
);
};
export default TranscriptTitle;

View File

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

View File

@@ -0,0 +1,64 @@
import { useContext, useEffect, useState } from "react";
import { DomainContext } from "../../domainContext";
import getApi from "../../lib/useApi";
import { useFiefAccessTokenInfo } from "@fief/fief/build/esm/nextjs/react";
export type Mp3Response = {
media: HTMLMediaElement | null;
loading: boolean;
getNow: () => void;
};
const useMp3 = (id: string, waiting?: boolean): Mp3Response => {
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const [later, setLater] = useState(waiting);
const [loading, setLoading] = useState<boolean>(false);
const api = getApi();
const { api_url } = useContext(DomainContext);
const accessTokenInfo = useFiefAccessTokenInfo();
const [serviceWorker, setServiceWorker] =
useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js").then((worker) => {
setServiceWorker(worker);
});
}
return () => {
serviceWorker?.unregister();
};
}, []);
useEffect(() => {
if (!navigator.serviceWorker) return;
if (!navigator.serviceWorker.controller) return;
if (!serviceWorker) return;
// Send the token to the service worker
navigator.serviceWorker.controller.postMessage({
type: "SET_AUTH_TOKEN",
token: accessTokenInfo?.access_token,
});
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => {
if (!id || !api || later) return;
// createa a audio element and set the source
setLoading(true);
const audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${id}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
setMedia(audioElement);
setLoading(false);
}, [id, api, later]);
const getNow = () => {
setLater(false);
};
return { media, loading, getNow };
};
export default useMp3;

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Participant } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type ErrorParticipants = {
error: Error;
loading: false;
response: null;
};
type LoadingParticipants = {
response: Participant[] | null;
loading: true;
error: null;
};
type SuccessParticipants = {
response: Participant[];
loading: boolean;
error: null;
};
export type UseParticipants = (
| ErrorParticipants
| LoadingParticipants
| SuccessParticipants
) & { 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 refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
useEffect(() => {
if (!transcriptId || !api) return;
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;
};
export default useParticipants;

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { GetTranscriptTopicWithWordsPerSpeaker } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type ErrorTopicWithWords = {
error: Error;
loading: false;
response: null;
};
type LoadingTopicWithWords = {
response: GetTranscriptTopicWithWordsPerSpeaker | null;
loading: true;
error: false;
};
type SuccessTopicWithWords = {
response: GetTranscriptTopicWithWordsPerSpeaker;
loading: false;
error: null;
};
export type UseTopicWithWords = { refetch: () => void } & (
| ErrorTopicWithWords
| LoadingTopicWithWords
| SuccessTopicWithWords
);
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 [count, setCount] = useState(0);
const refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
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;
};
export default useTopicWithWords;

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import { Topic } from "./webSocketTypes";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
import { GetTranscriptTopic } from "../../api";
type TranscriptTopics = {
topics: GetTranscriptTopic[] | null;
loading: boolean;
error: Error | null;
};
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;
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 };
};
export default useTopics;

View File

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { Page_GetTranscript_ } from "../../api";
type TranscriptList = {
response: Page_GetTranscript_ | null;
loading: boolean;
error: Error | null;
refetch: () => void;
};
//always protected
const useTranscriptList = (page: number): TranscriptList => {
const [response, setResponse] = useState<Page_GetTranscript_ | 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);
};
useEffect(() => {
if (!api) return;
setLoading(true);
api
.v1TranscriptsList({ 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 useTranscriptList;

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { AudioWaveform } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type AudioWaveFormResponse = {
waveform: AudioWaveform | null;
loading: boolean;
error: Error | null;
};
const useWaveform = (id: string, waiting: boolean): AudioWaveFormResponse => {
const [waveform, setWaveform] = useState<AudioWaveform | 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 || waiting) return;
setLoading(true);
api
.v1TranscriptGetAudioWaveform({ transcriptId: id })
.then((result) => {
setWaveform(result);
setLoading(false);
console.debug("Transcript waveform loaded:", result);
})
.catch((err) => {
setErrorState(err);
const shouldShowHuman = shouldShowError(err);
if (shouldShowHuman) {
setError(err, "There was an error loading the waveform");
} else {
setError(err);
}
});
}, [id, api, waiting]);
return { waveform, loading, error };
};
export default useWaveform;

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import Peer from "simple-peer";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { RtcOffer } from "../../api";
const useWebRTC = (
stream: MediaStream | null,
transcriptId: string | null,
): Peer => {
const [peer, setPeer] = useState<Peer | null>(null);
const { setError } = useError();
const api = useApi();
useEffect(() => {
if (!stream || !transcriptId) {
return;
}
console.debug("Using WebRTC", stream, transcriptId);
let p: Peer;
try {
p = new Peer({ initiator: true, stream: stream });
} catch (error) {
setError(error, "Error creating WebRTC");
return;
}
p.on("error", (err) => {
setError(new Error(`WebRTC error: ${err}`));
});
p.on("signal", (data: any) => {
if (!api) return;
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");
});
}
});
p.on("connect", () => {
console.log("WebRTC connected");
setPeer(p);
});
return () => {
p.destroy();
};
}, [stream, transcriptId]);
return peer;
};
export default useWebRTC;

View File

@@ -0,0 +1,466 @@
import { useContext, 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";
export type UseWebSockets = {
transcriptTextLive: string;
translateText: string;
accumulatedText: string;
title: string;
topics: Topic[];
finalSummary: FinalSummary;
status: Status;
waveform: AudioWaveform | null;
duration: number | null;
};
export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [transcriptTextLive, setTranscriptTextLive] = useState<string>("");
const [translateText, setTranslateText] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [textQueue, setTextQueue] = useState<string[]>([]);
const [translationQueue, setTranslationQueue] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [topics, setTopics] = useState<Topic[]>([]);
const [waveform, setWaveForm] = useState<AudioWaveform | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "",
});
const [status, setStatus] = useState<Status>({ value: "" });
const { setError } = useError();
const { websocket_url } = useContext(DomainContext);
const api = useApi();
const [accumulatedText, setAccumulatedText] = useState<string>("");
useEffect(() => {
if (isProcessing || textQueue.length === 0) {
return;
}
setIsProcessing(true);
const text = textQueue[0];
setTranscriptTextLive(text);
setTranslateText(translationQueue[0]);
const WPM_READING = 200 + textQueue.length * 10; // words per minute to read
const wordCount = text.split(/\s+/).length;
const delay = (wordCount / WPM_READING) * 60 * 1000;
setTimeout(() => {
setIsProcessing(false);
setTextQueue((prevQueue) => prevQueue.slice(1));
setTranslationQueue((prevQueue) => prevQueue.slice(1));
}, delay);
}, [textQueue, isProcessing]);
useEffect(() => {
document.onkeyup = (e) => {
if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") {
const segments: GetTranscriptSegmentTopic[] = [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
{
speaker: 3,
start: 90,
text: "This is the third speaker",
},
{
speaker: 4,
start: 90,
text: "This is the fourth speaker",
},
{
speaker: 5,
start: 123,
text: "This is the fifth speaker",
},
{
speaker: 6,
start: 300,
text: "This is the sixth speaker",
},
];
setTranscriptTextLive("Lorem Ipsum");
setStatus({ value: "recording" });
setTopics([
{
id: "1",
timestamp: 10,
duration: 10,
summary: "This is test topic 1",
title: "Topic 1: Introduction to Quantum Mechanics",
transcript:
"A brief overview of quantum mechanics and its principles.",
},
{
id: "2",
timestamp: 20,
duration: 10,
summary: "This is test topic 2",
title: "Topic 2: Machine Learning Algorithms",
transcript:
"Understanding the different types of machine learning algorithms.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "3",
timestamp: 30,
duration: 10,
summary: "This is test topic 3",
title: "Topic 3: Mental Health Awareness",
transcript: "Ways to improve mental health and reduce stigma.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "4",
timestamp: 40,
duration: 10,
summary: "This is test topic 4",
title: "Topic 4: Basics of Productivity",
transcript: "Tips and tricks to increase daily productivity.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "5",
timestamp: 50,
duration: 10,
summary: "This is test topic 5",
title: "Topic 5: Future of Aviation",
transcript:
"Exploring the advancements and possibilities in aviation.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
]);
setFinalSummary({ summary: "This is the final summary" });
}
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
setTranscriptTextLive(
"This text is in English, and it is a pretty long sentence to test the limits",
);
setAccumulatedText(
"This text is in English, and it is a pretty long sentence to test the limits. This text is in English, and it is a pretty long sentence to test the limits",
);
setStatus({ value: "processing" });
setTopics([
{
id: "1",
timestamp: 10,
duration: 10,
summary: "This is test topic 1",
title:
"Topic 1: Introduction to Quantum Mechanics, a brief overview of quantum mechanics and its principles.",
transcript:
"A brief overview of quantum mechanics and its principles.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "2",
timestamp: 20,
duration: 10,
summary: "This is test topic 2",
title:
"Topic 2: Machine Learning Algorithms, understanding the different types of machine learning algorithms.",
transcript:
"Understanding the different types of machine learning algorithms.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "3",
timestamp: 30,
duration: 10,
summary: "This is test topic 3",
title:
"Topic 3: Mental Health Awareness, ways to improve mental health and reduce stigma.",
transcript: "Ways to improve mental health and reduce stigma.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "4",
timestamp: 40,
duration: 10,
summary: "This is test topic 4",
title:
"Topic 4: Basics of Productivity, tips and tricks to increase daily productivity.",
transcript: "Tips and tricks to increase daily productivity.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
{
id: "5",
timestamp: 50,
duration: 10,
summary: "This is test topic 5",
title:
"Topic 5: Future of Aviation, exploring the advancements and possibilities in aviation.",
transcript:
"Exploring the advancements and possibilities in aviation.",
segments: [
{
speaker: 1,
start: 0,
text: "This is the transcription of an example title",
},
{
speaker: 2,
start: 10,
text: "This is the second speaker",
},
],
},
]);
setFinalSummary({ summary: "This is the final summary" });
}
};
if (!transcriptId || !api) return;
api?.v1TranscriptGetWebsocketEvents({ transcriptId }).then((result) => {});
const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url);
ws.onopen = () => {
console.debug("WebSocket connection opened");
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
try {
switch (message.event) {
case "TRANSCRIPT":
const newText = (message.data.text ?? "").trim();
const newTranslation = (message.data.translation ?? "").trim();
if (!newText) break;
console.debug("TRANSCRIPT event:", newText);
setTextQueue((prevQueue) => [...prevQueue, newText]);
setTranslationQueue((prevQueue) => [...prevQueue, newTranslation]);
setAccumulatedText((prevText) => prevText + " " + newText);
break;
case "TOPIC":
setTopics((prevTopics) => {
const topic = message.data as Topic;
const index = prevTopics.findIndex(
(prevTopic) => prevTopic.id === topic.id,
);
if (index >= 0) {
prevTopics[index] = topic;
return prevTopics;
}
setAccumulatedText((prevText) =>
prevText.slice(topic.transcript.length),
);
return [...prevTopics, topic];
});
console.debug("TOPIC event:", message.data);
break;
case "FINAL_SHORT_SUMMARY":
console.debug("FINAL_SHORT_SUMMARY event:", message.data);
break;
case "FINAL_LONG_SUMMARY":
if (message.data) {
setFinalSummary(message.data);
}
break;
case "FINAL_TITLE":
console.debug("FINAL_TITLE event:", message.data);
if (message.data) {
setTitle(message.data.title);
}
break;
case "WAVEFORM":
console.debug(
"WAVEFORM event length:",
message.data.waveform.length,
);
if (message.data) {
setWaveForm(message.data.waveform);
}
break;
case "DURATION":
console.debug("DURATION event:", message.data);
if (message.data) {
setDuration(message.data.duration);
}
break;
case "STATUS":
console.log("STATUS event:", message.data);
if (message.data.value === "error") {
setError(
Error("Websocket error status"),
"There was an error processing this meeting.",
);
}
setStatus(message.data);
if (message.data.value === "ended") {
ws.close();
}
break;
default:
setError(
new Error(`Received unknown WebSocket event: ${message.event}`),
);
}
} catch (error) {
setError(error);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
setError(new Error("A WebSocket error occurred."));
};
ws.onclose = (event) => {
console.debug("WebSocket connection closed");
switch (event.code) {
case 1000: // Normal Closure:
break;
case 1005: // Closure by client FF
break;
case 1001: // Navigate away
break;
default:
setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`),
"Disconnected from the server. Please refresh the page.",
);
console.log(
"Socket is closed. Reconnect will be attempted in 1 second.",
event.reason,
);
// todo handle reconnect with socket.io
}
};
return () => {
ws.close();
};
}, [transcriptId, !api]);
return {
transcriptTextLive,
translateText,
accumulatedText,
topics,
finalSummary,
title,
status,
waveform,
duration,
};
};

View File

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

View File

@@ -0,0 +1,20 @@
import { GetTranscriptTopic } from "../../api";
export type Topic = GetTranscriptTopic;
export type Transcript = {
text: string;
};
export type FinalSummary = {
summary: string;
};
export type Status = {
value: string;
};
export type TranslatedTopic = {
text: string;
translation: string;
};