mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Remove domain segment
This commit is contained in:
303
www/app/(app)/browse/page.tsx
Normal file
303
www/app/(app)/browse/page.tsx
Normal 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
|
||||
<Link href="/transcripts/new" className="underline">
|
||||
record a meeting
|
||||
</Link>
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
www/app/(app)/browse/pagination.tsx
Normal file
79
www/app/(app)/browse/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
346
www/app/(app)/rooms/page.tsx
Normal file
346
www/app/(app)/rooms/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
www/app/(app)/rooms/useRoomList.tsx
Normal file
47
www/app/(app)/rooms/useRoomList.tsx
Normal 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;
|
||||
123
www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
Normal file
123
www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 +
|
||||
<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 +
|
||||
<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;
|
||||
@@ -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>
|
||||
);
|
||||
163
www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx
Normal file
163
www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
www/app/(app)/transcripts/[transcriptId]/correct/topicPlayer.tsx
Normal file
245
www/app/(app)/transcripts/[transcriptId]/correct/topicPlayer.tsx
Normal 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> Play
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={pause}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Pause
|
||||
</Button>
|
||||
)}
|
||||
</WrapItem>
|
||||
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
|
||||
<Button
|
||||
disabled={!selectedTime}
|
||||
onClick={playSelection}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Kbd color="blue.600">,</Kbd> Play selection
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicPlayer;
|
||||
220
www/app/(app)/transcripts/[transcriptId]/correct/topicWords.tsx
Normal file
220
www/app/(app)/transcripts/[transcriptId]/correct/topicWords.tsx
Normal 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)} :
|
||||
</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;
|
||||
20
www/app/(app)/transcripts/[transcriptId]/correct/types.ts
Normal file
20
www/app/(app)/transcripts/[transcriptId]/correct/types.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
154
www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
Normal file
154
www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
www/app/(app)/transcripts/[transcriptId]/page.tsx
Normal file
147
www/app/(app)/transcripts/[transcriptId]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
www/app/(app)/transcripts/[transcriptId]/record/page.tsx
Normal file
130
www/app/(app)/transcripts/[transcriptId]/record/page.tsx
Normal 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;
|
||||
159
www/app/(app)/transcripts/[transcriptId]/shareModal.tsx
Normal file
159
www/app/(app)/transcripts/[transcriptId]/shareModal.tsx
Normal 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;
|
||||
113
www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
Normal file
113
www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
Normal 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;
|
||||
28
www/app/(app)/transcripts/audioInputsDropdown.tsx
Normal file
28
www/app/(app)/transcripts/audioInputsDropdown.tsx
Normal 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;
|
||||
45
www/app/(app)/transcripts/createTranscript.ts
Normal file
45
www/app/(app)/transcripts/createTranscript.ts
Normal 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;
|
||||
13
www/app/(app)/transcripts/disconnectedIndicator.tsx
Normal file
13
www/app/(app)/transcripts/disconnectedIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
www/app/(app)/transcripts/fileUploadButton.tsx
Normal file
90
www/app/(app)/transcripts/fileUploadButton.tsx
Normal 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...
|
||||
<CircularProgress size="20px" value={progress} />
|
||||
</>
|
||||
) : (
|
||||
<>Select File</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
www/app/(app)/transcripts/liveTranscription.tsx
Normal file
23
www/app/(app)/transcripts/liveTranscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
www/app/(app)/transcripts/mockTopics.json
Normal file
32
www/app/(app)/transcripts/mockTopics.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
15
www/app/(app)/transcripts/modal.tsx
Normal file
15
www/app/(app)/transcripts/modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
www/app/(app)/transcripts/new/page.tsx
Normal file
165
www/app/(app)/transcripts/new/page.tsx
Normal 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;
|
||||
194
www/app/(app)/transcripts/player.tsx
Normal file
194
www/app/(app)/transcripts/player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
www/app/(app)/transcripts/recorder.tsx
Normal file
322
www/app/(app)/transcripts/recorder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
www/app/(app)/transcripts/scrollToBottom.tsx
Normal file
23
www/app/(app)/transcripts/scrollToBottom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
www/app/(app)/transcripts/shareAndPrivacy.tsx
Normal file
149
www/app/(app)/transcripts/shareAndPrivacy.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
www/app/(app)/transcripts/shareCopy.tsx
Normal file
62
www/app/(app)/transcripts/shareCopy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
www/app/(app)/transcripts/shareLink.tsx
Normal file
74
www/app/(app)/transcripts/shareLink.tsx
Normal 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;
|
||||
36
www/app/(app)/transcripts/shareZulip.tsx
Normal file
36
www/app/(app)/transcripts/shareZulip.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
259
www/app/(app)/transcripts/topicList.tsx
Normal file
259
www/app/(app)/transcripts/topicList.tsx
Normal 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"
|
||||
>
|
||||
[{formatTime(topic.timestamp)}] - [
|
||||
{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>
|
||||
);
|
||||
}
|
||||
112
www/app/(app)/transcripts/transcriptTitle.tsx
Normal file
112
www/app/(app)/transcripts/transcriptTitle.tsx
Normal 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;
|
||||
130
www/app/(app)/transcripts/useAudioDevice.ts
Normal file
130
www/app/(app)/transcripts/useAudioDevice.ts
Normal 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;
|
||||
64
www/app/(app)/transcripts/useMp3.ts
Normal file
64
www/app/(app)/transcripts/useMp3.ts
Normal 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;
|
||||
74
www/app/(app)/transcripts/useParticipants.ts
Normal file
74
www/app/(app)/transcripts/useParticipants.ts
Normal 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;
|
||||
79
www/app/(app)/transcripts/useTopicWithWords.ts
Normal file
79
www/app/(app)/transcripts/useTopicWithWords.ts
Normal 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;
|
||||
46
www/app/(app)/transcripts/useTopics.ts
Normal file
46
www/app/(app)/transcripts/useTopics.ts
Normal 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;
|
||||
70
www/app/(app)/transcripts/useTranscript.ts
Normal file
70
www/app/(app)/transcripts/useTranscript.ts
Normal 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;
|
||||
47
www/app/(app)/transcripts/useTranscriptList.ts
Normal file
47
www/app/(app)/transcripts/useTranscriptList.ts
Normal 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;
|
||||
44
www/app/(app)/transcripts/useWaveform.ts
Normal file
44
www/app/(app)/transcripts/useWaveform.ts
Normal 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;
|
||||
71
www/app/(app)/transcripts/useWebRTC.ts
Normal file
71
www/app/(app)/transcripts/useWebRTC.ts
Normal 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;
|
||||
466
www/app/(app)/transcripts/useWebSockets.ts
Normal file
466
www/app/(app)/transcripts/useWebSockets.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
7
www/app/(app)/transcripts/waveformLoading.tsx
Normal file
7
www/app/(app)/transcripts/waveformLoading.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Center, Spinner } from "@chakra-ui/react";
|
||||
|
||||
export default () => (
|
||||
<Center h={14}>
|
||||
<Spinner speed="1s"></Spinner>
|
||||
</Center>
|
||||
);
|
||||
20
www/app/(app)/transcripts/webSocketTypes.ts
Normal file
20
www/app/(app)/transcripts/webSocketTypes.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user