Merge pull request #348 from Monadical-SAS/UI-Meeting

UI meeting
This commit is contained in:
2024-06-28 18:15:18 +02:00
committed by GitHub
24 changed files with 1091 additions and 808 deletions

View File

@@ -100,6 +100,7 @@ export default function TranscriptBrowser() {
api api
.v1TranscriptDelete(transcriptToDeleteId) .v1TranscriptDelete(transcriptToDeleteId)
.then(() => { .then(() => {
refetch();
setDeletionLoading(false); setDeletionLoading(false);
refetch(); refetch();
onCloseDeletion(); onCloseDeletion();

View File

@@ -0,0 +1,150 @@
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[];
};
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,
);
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
setError(err, "Failed to update long summary.");
}
};
const onEditClick = () => {
setPreEditSummary(editedSummary);
setIsEditMode(true);
};
const onDiscardClick = () => {
setEditedSummary(preEditSummary);
setIsEditMode(false);
};
const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptResponse.id);
setIsEditMode(false);
};
const handleTextAreaKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onDiscardClick();
}
if (e.key === "Enter" && e.shiftKey) {
onSaveClick();
e.preventDefault(); // prevent the default action of adding a new line
}
};
return (
<Flex
direction="column"
maxH={"100%"}
h={"100%"}
overflowY={isEditMode ? "hidden" : "auto"}
pb={4}
>
<Flex dir="row" justify="start" align="center" wrap={"wrap-reverse"}>
<Heading size={{ base: "md" }}>Summary</Heading>
{isEditMode && (
<>
<Spacer />
<Button
onClick={onDiscardClick}
colorScheme="gray"
variant={"text"}
>
Discard
</Button>
<Button onClick={onSaveClick} colorScheme="blue">
Save
</Button>
</>
)}
{!isEditMode && (
<>
<IconButton
icon={<FaPen />}
aria-label="Edit Summary"
onClick={onEditClick}
/>
<Spacer />
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</>
)}
</Flex>
{isEditMode ? (
<Textarea
value={editedSummary}
onChange={(e) => setEditedSummary(e.target.value)}
className="markdown"
onKeyDown={(e) => handleTextAreaKeyDown(e)}
h={"100%"}
resize={"none"}
mt={2}
/>
) : (
<div ref={finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown>
</div>
)}
</Flex>
);
}

View File

@@ -8,16 +8,11 @@ import { TopicList } from "../topicList";
import { Topic } from "../webSocketTypes"; import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import "../../../styles/button.css"; import "../../../styles/button.css";
import FinalSummary from "../finalSummary"; import FinalSummary from "./finalSummary";
import ShareLink from "../shareLink";
import QRCode from "react-qr-code";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import ShareModal from "./shareModal";
import Player from "../player"; import Player from "../player";
import WaveformLoading from "../waveformLoading";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { featureEnabled } from "../../domainContext"; import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import { toShareMode } from "../../../lib/shareMode";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -34,7 +29,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const waveform = useWaveform(transcriptId); const waveform = useWaveform(transcriptId);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const mp3 = useMp3(transcriptId); const mp3 = useMp3(transcriptId);
const [showModal, setShowModal] = useState(false);
useEffect(() => { useEffect(() => {
const statusToRedirect = ["idle", "recording", "processing"]; const statusToRedirect = ["idle", "recording", "processing"];
@@ -43,18 +37,11 @@ export default function TranscriptDetails(details: TranscriptDetails) {
// Shallow redirection does not work on NextJS 13 // Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110 // https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540 // https://github.com/vercel/next.js/discussions/49540
router.push(newUrl, undefined); router.replace(newUrl);
// history.replaceState({}, "", newUrl); // history.replaceState({}, "", newUrl);
} }
}, [transcript.response?.status]); }, [transcript.response?.status]);
const fullTranscript =
topics.topics
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")
.trim() || "";
if (transcript.error || topics?.error) { if (transcript.error || topics?.error) {
return ( return (
<Modal <Modal
@@ -69,25 +56,17 @@ export default function TranscriptDetails(details: TranscriptDetails) {
} }
return ( return (
<div className="grid grid-rows-layout-topbar h-full max-h-full gap-2 lg:gap-4"> <>
{featureEnabled("sendToZulip") && ( <Grid
<ShareModal templateColumns="1fr"
transcript={transcript.response} templateRows="auto minmax(0, 1fr)"
topics={topics ? topics.topics : null} gap={4}
show={showModal} mt={4}
setShow={(v) => setShowModal(v)} mb={4}
/> >
)} {waveform.waveform && mp3.media && topics.topics ? (
<div className="flex flex-col">
{transcript?.response?.title && (
<TranscriptTitle
title={transcript.response.title}
transcriptId={transcript.response.id}
/>
)}
{waveform.waveform && mp3.media ? (
<Player <Player
topics={topics?.topics || []} topics={topics?.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform.waveform} waveform={waveform.waveform}
media={mp3.media} media={mp3.media}
@@ -96,58 +75,64 @@ export default function TranscriptDetails(details: TranscriptDetails) {
) : waveform.error ? ( ) : waveform.error ? (
<div>"error loading this recording"</div> <div>"error loading this recording"</div>
) : ( ) : (
<WaveformLoading /> <Skeleton h={14} />
)} )}
</div> <Grid
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full"> templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
<TopicList templateRows={{
topics={topics.topics || []} base: "auto minmax(0, 1fr) minmax(0, 1fr)",
useActiveTopic={useActiveTopic} md: "auto minmax(0, 1fr)",
autoscroll={false} }}
transcriptId={transcriptId} gap={2}
/> padding={4}
paddingBottom={0}
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4"> background="gray.bg"
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full"> border={"2px solid"}
{transcript.response.long_summary ? ( borderColor={"gray.bg"}
borderRadius={8}
>
<GridItem
display="flex"
flexDir="row"
alignItems={"center"}
colSpan={{ base: 1, md: 2 }}
>
<TranscriptTitle
title={transcript.response.title || "Unamed Transcript"}
transcriptId={transcriptId}
/>
</GridItem>
<TopicList
topics={topics.topics || []}
useActiveTopic={useActiveTopic}
autoscroll={false}
transcriptId={transcriptId}
status={transcript.response?.status}
currentTranscriptText=""
/>
{transcript.response && topics.topics ? (
<>
<FinalSummary <FinalSummary
fullTranscript={fullTranscript} transcriptResponse={transcript.response}
summary={transcript.response.long_summary} topicsResponse={topics.topics}
transcriptId={transcript.response.id}
openZulipModal={() => setShowModal(true)}
/> />
) : ( </>
) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}>
<div className="flex flex-col h-full justify-center content-center"> <div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? ( {transcript.response.status == "processing" ? (
<p>Loading Transcript</p> <Text>Loading Transcript</Text>
) : ( ) : (
<p> <Text>
There was an error generating the final summary, please come There was an error generating the final summary, please come
back later back later
</p> </Text>
)} )}
</div> </div>
)} </Flex>
</section> )}
</Grid>
<section className="flex items-center"> </Grid>
<div className="mr-4 hidden md:block h-auto"> </>
<QRCode
value={`${location.origin}/transcripts/${details.params.transcriptId}`}
level="L"
size={98}
/>
</div>
<div className="flex-grow max-w-full">
<ShareLink
transcriptId={transcript.response.id}
userId={transcript.response.user_id}
shareMode={toShareMode(transcript.response.share_mode)}
/>
</div>
</section>
</div>
</div>
</div>
); );
} }

View File

@@ -1,22 +1,18 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Recorder from "../../recorder"; import Recorder from "../../recorder";
import { TopicList } from "../../topicList"; import { TopicList } from "../../topicList";
import useWebRTC from "../../useWebRTC";
import useTranscript from "../../useTranscript"; import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import useAudioDevice from "../../useAudioDevice";
import "../../../../styles/button.css"; import "../../../../styles/button.css";
import { Topic } from "../../webSocketTypes"; import { Topic } from "../../webSocketTypes";
import LiveTrancription from "../../liveTranscription";
import DisconnectedIndicator from "../../disconnectedIndicator";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear } from "@fortawesome/free-solid-svg-icons";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Player from "../../player"; import Player from "../../player";
import useMp3 from "../../useMp3"; import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading"; import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -25,59 +21,41 @@ type TranscriptDetails = {
}; };
const TranscriptRecord = (details: TranscriptDetails) => { const TranscriptRecord = (details: TranscriptDetails) => {
const [stream, setStream] = useState<MediaStream | null>(null); const transcript = useTranscript(details.params.transcriptId);
const [disconnected, setDisconnected] = useState<boolean>(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
useEffect(() => {
if (process.env.NEXT_PUBLIC_ENV === "development") {
document.onkeyup = (e) => {
if (e.key === "d") {
setDisconnected((prev) => !prev);
}
};
}
}, []);
const transcript = useTranscript(details.params.transcriptId);
const webRTC = useWebRTC(stream, details.params.transcriptId);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(details.params.transcriptId);
const { audioDevices, getAudioStream } = useAudioDevice();
const [recordedTime, setRecordedTime] = useState(0);
const [startTime, setStartTime] = useState(0);
const [transcriptStarted, setTranscriptStarted] = useState(false);
let mp3 = useMp3(details.params.transcriptId, true); let mp3 = useMp3(details.params.transcriptId, true);
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle"
);
useEffect(() => { useEffect(() => {
if (!transcriptStarted && webSockets.transcriptText.length !== 0) if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
setTranscriptStarted(true); setTranscriptStarted(true);
}, [webSockets.transcriptText]); }, [webSockets.transcriptTextLive]);
useEffect(() => { useEffect(() => {
const statusToRedirect = ["ended", "error"]; //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");
//TODO if has no topic and is error, get back to new
if (
transcript.response?.status &&
(statusToRedirect.includes(transcript.response?.status) ||
statusToRedirect.includes(webSockets.status.value))
) {
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + details.params.transcriptId;
// 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); router.replace(newUrl);
// history.replaceState({}, "", newUrl); }
} // history.replaceState({}, "", newUrl);
}, [webSockets.status.value, transcript.response?.status]); }, [webSockets.status.value, transcript.response?.status]);
useEffect(() => { useEffect(() => {
if (transcript.response?.status === "ended") mp3.getNow(); if (webSockets.waveform && webSockets.waveform) mp3.getNow();
}, [transcript.response]); }, [webSockets.waveform, webSockets.duration]);
useEffect(() => { useEffect(() => {
lockWakeState(); lockWakeState();
@@ -87,87 +65,60 @@ const TranscriptRecord = (details: TranscriptDetails) => {
}, []); }, []);
return ( return (
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full h-full"> <Grid
{webSockets.waveform && webSockets.duration && mp3?.media ? ( templateColumns="1fr"
<Player templateRows="auto minmax(0, 1fr) "
topics={webSockets.topics || []} gap={4}
useActiveTopic={useActiveTopic} mt={4}
waveform={webSockets.waveform} mb={4}
media={mp3.media} >
mediaDuration={webSockets.duration} {status == "processing" ? (
/>
) : recordedTime ? (
<WaveformLoading /> <WaveformLoading />
) : ( ) : (
<Recorder // todo: only start recording animation when you get "recorded" status
setStream={setStream} <Recorder transcriptId={details.params.transcriptId} status={status} />
onStop={() => {
setStream(null);
setRecordedTime(Date.now() - startTime);
webRTC?.send(JSON.stringify({ cmd: "STOP" }));
}}
onRecord={() => {
setStartTime(Date.now());
}}
getAudioStream={getAudioStream}
audioDevices={audioDevices}
transcriptId={details.params.transcriptId}
/>
)} )}
<Grid
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-mobile-inner lg:grid-rows-1 gap-2 lg:gap-4 h-full"> templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
templateRows={{
base: "minmax(0, 1fr) minmax(0, 1fr)",
md: "minmax(0, 1fr)",
}}
gap={2}
padding={4}
paddingBottom={0}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
>
<TopicList <TopicList
topics={webSockets.topics} topics={webSockets.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={true} autoscroll={true}
transcriptId={details.params.transcriptId} transcriptId={details.params.transcriptId}
status={status}
currentTranscriptText={webSockets.accumulatedText}
/> />
<Box>
<section {!transcriptStarted ? (
className={`w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4`} <Box textAlign={"center"} textColor="gray">
> <Text>
{!recordedTime ? ( The conversation transcript will appear here shortly after you
<> start recording.
{transcriptStarted && ( </Text>
<h2 className="md:text-lg font-bold">Transcription</h2> </Box>
)}
<div className="flex flex-col justify-center align center text-center h-full">
<div className="py-2 h-auto">
{!transcriptStarted ? (
<div className="text-center text-gray-500">
The conversation transcript will appear here shortly after
you start recording.
</div>
) : (
<LiveTrancription
text={webSockets.transcriptText}
translateText={webSockets.translateText}
/>
)}
</div>
</div>
</>
) : ( ) : (
<div className="flex flex-col justify-center align center text-center h-full text-gray-500"> status === "recording" && (
<div className="p-2 md:p-4"> <LiveTrancription
<FontAwesomeIcon text={webSockets.transcriptTextLive}
icon={faGear} translateText={webSockets.translateText}
className="animate-spin-slow h-14 w-14 md:h-20 md:w-20" />
/> )
</div>
<p>
We are generating the final summary for you. This may take a
couple of minutes. Please do not navigate away from the page
during this time.
</p>
{/* NTH If login required remove last sentence */}
</div>
)} )}
</section> </Box>
</div> </Grid>
</Grid>
{disconnected && <DisconnectedIndicator />}
</div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React from "react";
import Dropdown, { Option } from "react-dropdown"; import Dropdown, { Option } from "react-dropdown";
import "react-dropdown/style.css"; import "react-dropdown/style.css";

View File

@@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
import { Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post } from "../../api"; import { Button } from "@chakra-ui/react";
type FileUploadButton = { type FileUploadButton = {
transcriptId: string; transcriptId: string;
disabled?: boolean;
}; };
export default function FileUploadButton(props: FileUploadButton) { export default function FileUploadButton(props: FileUploadButton) {
@@ -32,12 +33,14 @@ export default function FileUploadButton(props: FileUploadButton) {
return ( return (
<> <>
<button <Button
className="bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg"
onClick={triggerFileUpload} onClick={triggerFileUpload}
colorScheme="blue"
mr={2}
isDisabled={props.disabled}
> >
Upload File Upload File
</button> </Button>
<input <input
type="file" type="file"

View File

@@ -1,186 +0,0 @@
import { useRef, useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import "../../styles/markdown.css";
import { featureEnabled } from "../domainContext";
import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi";
type FinalSummaryProps = {
summary: string;
fullTranscript: string;
transcriptId: string;
openZulipModal: () => void;
};
export default function FinalSummary(props: FinalSummaryProps) {
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState(props.summary);
const [editedSummary, setEditedSummary] = useState(props.summary);
const updateSummary = async (newSummary: string, transcriptId: string) => {
try {
const api = useApi();
const requestBody: UpdateTranscript = {
long_summary: newSummary,
};
const updatedTranscript = await api?.v1TranscriptUpdate(
transcriptId,
requestBody,
);
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
}
};
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 = props.fullTranscript;
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedTranscript(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedTranscript(false), 2000);
});
};
const onEditClick = () => {
setPreEditSummary(editedSummary);
setIsEditMode(true);
};
const onDiscardClick = () => {
setEditedSummary(preEditSummary);
setIsEditMode(false);
};
const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptId);
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 (
<div
className={
(isEditMode ? "overflow-y-none" : "overflow-y-auto") +
" max-h-full flex flex-col h-full"
}
>
<div className="flex flex-row flex-wrap-reverse justify-between items-center">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold">
Final Summary
</h2>
<div className="ml-auto flex space-x-2 mb-2">
{isEditMode && (
<>
<button
onClick={onDiscardClick}
className={"text-gray-500 text-sm hover:underline"}
>
Discard Changes
</button>
<button
onClick={onSaveClick}
className={
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2"
}
>
Save Changes
</button>
</>
)}
{!isEditMode && (
<>
{featureEnabled("sendToZulip") && (
<button
className={
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
onClick={() => props.openZulipModal()}
>
<span className="text-xs"> Zulip</span>
</button>
)}
<button
onClick={onEditClick}
className={
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
>
<span className="text-xs"> Summary</span>
</button>
<button
onClick={onCopyTranscriptClick}
className={
(isCopiedTranscript ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
style={{ minHeight: "30px" }}
>
<span className="text-xs">
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</span>
</button>
<button
onClick={onCopySummaryClick}
className={
(isCopiedSummary ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
style={{ minHeight: "30px" }}
>
<span className="text-xs">
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</span>
</button>
</>
)}
</div>
</div>
{isEditMode ? (
<div className="flex-grow overflow-y-none">
<textarea
value={editedSummary}
onChange={(e) => setEditedSummary(e.target.value)}
className="markdown w-full h-full d-block p-2 border rounded shadow-sm"
onKeyDown={(e) => handleTextAreaKeyDown(e)}
/>
</div>
) : (
<p ref={finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown>
</p>
)}
</div>
);
}

View File

@@ -3,10 +3,13 @@ import React, { useRef, useEffect, useState } from "react";
import WaveSurfer from "wavesurfer.js"; import WaveSurfer from "wavesurfer.js";
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js"; import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
import { formatTime } from "../../lib/time"; import { formatTime, formatTimeMs } from "../../lib/time";
import { Topic } from "./webSocketTypes"; import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api"; import { AudioWaveform } from "../../api";
import { waveSurferStyles } from "../../styles/recorder"; 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 = { type PlayerProps = {
topics: Topic[]; topics: Topic[];
@@ -27,25 +30,31 @@ export default function Player(props: PlayerProps) {
const [waveRegions, setWaveRegions] = useState<RegionsPlugin | null>(null); const [waveRegions, setWaveRegions] = useState<RegionsPlugin | null>(null);
const [activeTopic, setActiveTopic] = props.useActiveTopic; const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics); 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 // Waveform setup
useEffect(() => { useEffect(() => {
if (waveformRef.current) { if (waveformRef.current) {
// XXX duration is required to prevent recomputing peaks from audio
// However, the current waveform returns only the peaks, and no duration
// And the backend does not save duration properly.
// So at the moment, we deduct the duration from the topics.
// This is not ideal, but it works for now.
const _wavesurfer = WaveSurfer.create({ const _wavesurfer = WaveSurfer.create({
container: waveformRef.current, container: waveformRef.current,
peaks: props.waveform.data, peaks: [props.waveform.data],
hideScrollbar: true,
autoCenter: true,
barWidth: 2,
height: "auto", height: "auto",
duration: Math.floor(props.mediaDuration / 1000), duration: Math.floor(props.mediaDuration / 1000),
media: props.media, media: props.media,
...waveSurferStyles.player, ...waveSurferStyles.playerSettings,
}); });
// styling // styling
@@ -84,8 +93,19 @@ export default function Player(props: PlayerProps) {
}, [props.media, wavesurfer]); }, [props.media, wavesurfer]);
useEffect(() => { useEffect(() => {
if (!waveRegions) return;
topicsRef.current = props.topics; topicsRef.current = props.topics;
renderMarkers(); 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]); }, [props.topics, waveRegions]);
const renderMarkers = () => { const renderMarkers = () => {
@@ -96,22 +116,28 @@ export default function Player(props: PlayerProps) {
for (let topic of topicsRef.current) { for (let topic of topicsRef.current) {
const content = document.createElement("div"); const content = document.createElement("div");
content.setAttribute("style", waveSurferStyles.marker); content.setAttribute("style", waveSurferStyles.marker);
content.onmouseover = () => { content.onmouseover = (e) => {
content.style.backgroundColor = content.style.backgroundColor =
waveSurferStyles.markerHover.backgroundColor; waveSurferStyles.markerHover.backgroundColor;
content.style.zIndex = "999";
content.style.width = "300px"; content.style.width = "300px";
if (content.parentElement) {
content.parentElement.style.zIndex = "999";
}
}; };
content.onmouseout = () => { content.onmouseout = () => {
content.setAttribute("style", waveSurferStyles.marker); content.setAttribute("style", waveSurferStyles.marker);
if (content.parentElement) {
content.parentElement.style.zIndex = "0";
}
}; };
content.textContent = topic.title; content.textContent = topic.title;
const region = waveRegions.addRegion({ const region = waveRegions.addRegion({
start: topic.timestamp, start: topic.timestamp,
content, content,
color: "f00",
drag: false, drag: false,
resize: false,
top: 0,
}); });
region.on("click", (e) => { region.on("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -132,32 +158,37 @@ export default function Player(props: PlayerProps) {
}; };
const timeLabel = () => { const timeLabel = () => {
if (props.mediaDuration) if (props.mediaDuration && Math.floor(props.mediaDuration / 1000) > 0)
return `${formatTime(currentTime)}/${formatTime(props.mediaDuration)}`; return `${formatTime(currentTime)}/${formatTimeMs(props.mediaDuration)}`;
return ""; return "";
}; };
return ( return (
<div className="flex items-center w-full relative"> <Flex className="flex items-center w-full relative">
<div className="flex-grow items-end relative"> <IconButton
<div aria-label={isPlaying ? "Pause" : "Play"}
ref={waveformRef} icon={isPlaying ? <PauseIcon /> : <PlayIcon />}
className="flex-grow rounded-lg md:rounded-xl h-20" variant={"ghost"}
></div> colorScheme={"blue"}
<div className="absolute right-2 bottom-0">{timeLabel()}</div> mr={2}
</div>
<button
className={`${
isPlaying
? "bg-orange-400 hover:bg-orange-500 focus-visible:bg-orange-500"
: "bg-green-400 hover:bg-green-500 focus-visible:bg-green-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
id="play-btn" id="play-btn"
onClick={handlePlayClick} onClick={handlePlayClick}
> />
{isPlaying ? "Pause" : "Play"}
</button> <Box position="relative" flex={1}>
</div> <Box ref={waveformRef} height={14}></Box>
<Box
zIndex={50}
backgroundColor="rgba(255, 255, 255, 0.5)"
fontSize={"sm"}
shadow={"0px 0px 4px 0px white"}
position={"absolute"}
right={0}
bottom={0}
>
{timeLabel()}
</Box>
</Box>
</Flex>
); );
} }

View File

@@ -3,39 +3,50 @@ import React, { useRef, useEffect, useState } from "react";
import WaveSurfer from "wavesurfer.js"; import WaveSurfer from "wavesurfer.js";
import RecordPlugin from "../../lib/custom-plugins/record"; import RecordPlugin from "../../lib/custom-plugins/record";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { formatTime, formatTimeMs } from "../../lib/time";
import { faMicrophone } from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../../lib/time";
import AudioInputsDropdown from "./audioInputsDropdown";
import { Option } from "react-dropdown";
import { waveSurferStyles } from "../../styles/recorder"; import { waveSurferStyles } from "../../styles/recorder";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import FileUploadButton from "./fileUploadButton"; 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 = { type RecorderProps = {
setStream: React.Dispatch<React.SetStateAction<MediaStream | null>>;
onStop: () => void;
onRecord?: () => void;
getAudioStream: (deviceId) => Promise<MediaStream | null>;
audioDevices: Option[];
transcriptId: string; transcriptId: string;
status: string;
}; };
export default function Recorder(props: RecorderProps) { export default function Recorder(props: RecorderProps) {
const waveformRef = useRef<HTMLDivElement>(null); const waveformRef = useRef<HTMLDivElement>(null);
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
const [record, setRecord] = useState<RecordPlugin | null>(null); const [record, setRecord] = useState<RecordPlugin | null>(null);
const [isRecording, setIsRecording] = useState<boolean>(false); const [isRecording, setIsRecording] = useState<boolean>(false);
const [hasRecorded, setHasRecorded] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [timeInterval, setTimeInterval] = useState<number | null>(null);
const [duration, setDuration] = useState<number>(0); const [duration, setDuration] = useState<number>(0);
const [deviceId, setDeviceId] = useState<string | null>(null); const [deviceId, setDeviceId] = useState<string | null>(null);
const [recordStarted, setRecordStarted] = useState(false);
const [showDevices, setShowDevices] = useState(false);
const { setError } = useError(); 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 // Function used to setup keyboard shortcuts for the streamdeck
const setupProjectorKeys = (): (() => void) => { const setupProjectorKeys = (): (() => void) => {
@@ -106,22 +117,13 @@ export default function Recorder(props: RecorderProps) {
waveSurferStyles.playerStyle.backgroundColor; waveSurferStyles.playerStyle.backgroundColor;
wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius; wsWrapper.style.borderRadius = waveSurferStyles.playerStyle.borderRadius;
_wavesurfer.on("play", () => {
setIsPlaying(true);
});
_wavesurfer.on("pause", () => {
setIsPlaying(false);
});
_wavesurfer.on("timeupdate", setCurrentTime); _wavesurfer.on("timeupdate", setCurrentTime);
setRecord(_wavesurfer.registerPlugin(RecordPlugin.create())); setRecord(_wavesurfer.registerPlugin(RecordPlugin.create()));
setWavesurfer(_wavesurfer);
return () => { return () => {
_wavesurfer.destroy(); _wavesurfer.destroy();
setIsRecording(false); setIsRecording(false);
setIsPlaying(false);
setCurrentTime(0); setCurrentTime(0);
}; };
} }
@@ -130,7 +132,7 @@ export default function Recorder(props: RecorderProps) {
useEffect(() => { useEffect(() => {
if (isRecording) { if (isRecording) {
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
setCurrentTime((prev) => prev + 1); setCurrentTime(Date.now() - startTime);
}, 1000); }, 1000);
setTimeInterval(interval); setTimeInterval(interval);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -147,20 +149,20 @@ export default function Recorder(props: RecorderProps) {
if (!record) return console.log("no record"); if (!record) return console.log("no record");
if (record.isRecording()) { if (record.isRecording()) {
if (props.onStop) props.onStop(); setStream(null);
webRTC?.send(JSON.stringify({ cmd: "STOP" }));
record.stopRecording(); record.stopRecording();
if (screenMediaStream) { if (screenMediaStream) {
screenMediaStream.getTracks().forEach((t) => t.stop()); screenMediaStream.getTracks().forEach((t) => t.stop());
} }
setIsRecording(false); setIsRecording(false);
setHasRecorded(true);
setScreenMediaStream(null); setScreenMediaStream(null);
setDestinationStream(null); setDestinationStream(null);
} else { } else {
if (props.onRecord) props.onRecord(); const stream = await getMicrophoneStream();
const stream = await getCurrentStream(); setStartTime(Date.now());
if (props.setStream) props.setStream(stream); setStream(stream);
if (stream) { if (stream) {
await record.startRecording(stream); await record.startRecording(stream);
setIsRecording(true); setIsRecording(true);
@@ -198,7 +200,7 @@ export default function Recorder(props: RecorderProps) {
if (destinationStream !== null) return console.log("already recording"); if (destinationStream !== null) return console.log("already recording");
// connect mic audio (microphone) // connect mic audio (microphone)
const micStream = await getCurrentStream(); const micStream = await getMicrophoneStream();
if (!micStream) { if (!micStream) {
console.log("no microphone audio"); console.log("no microphone audio");
return; return;
@@ -227,7 +229,7 @@ export default function Recorder(props: RecorderProps) {
useEffect(() => { useEffect(() => {
if (!record) return; if (!record) return;
if (!destinationStream) return; if (!destinationStream) return;
if (props.setStream) props.setStream(destinationStream); setStream(destinationStream);
if (destinationStream) { if (destinationStream) {
record.startRecording(destinationStream); record.startRecording(destinationStream);
setIsRecording(true); setIsRecording(true);
@@ -238,115 +240,87 @@ export default function Recorder(props: RecorderProps) {
startTabRecording(); startTabRecording();
}, [record, screenMediaStream]); }, [record, screenMediaStream]);
const handlePlayClick = () => {
wavesurfer?.playPause();
};
const timeLabel = () => { const timeLabel = () => {
if (isRecording) return formatTime(currentTime); if (isRecording) return formatTimeMs(currentTime);
if (duration) return `${formatTime(currentTime)}/${formatTime(duration)}`; if (duration) return `${formatTimeMs(currentTime)}/${formatTime(duration)}`;
return ""; return "";
}; };
const getCurrentStream = async () => { const getMicrophoneStream = async () => {
setRecordStarted(true); return deviceId && getAudioStream ? await getAudioStream(deviceId) : null;
return deviceId && props.getAudioStream
? await props.getAudioStream(deviceId)
: null;
}; };
useEffect(() => { useEffect(() => {
if (props.audioDevices && props.audioDevices.length > 0) { if (audioDevices && audioDevices.length > 0) {
setDeviceId(props.audioDevices[0].value); setDeviceId(audioDevices[0].value);
} }
}, [props.audioDevices]); }, [audioDevices]);
return ( return (
<div className="flex items-center w-full relative"> <Flex className="flex items-center w-full relative">
<div className="flex-grow items-end relative"> <IconButton
<div aria-label={isRecording ? "Stop" : "Record"}
ref={waveformRef} icon={isRecording ? <StopRecordIcon /> : <PlayIcon />}
className="flex-grow rounded-lg md:rounded-xl h-20" variant={"ghost"}
></div> colorScheme={"blue"}
<div className="absolute right-2 bottom-0"> mr={2}
{isRecording && ( onClick={handleRecClick}
<div className="inline-block bg-red-500 rounded-full w-2 h-2 my-auto mr-1 animate-ping"></div> />
)} <FileUploadButton
transcriptId={props.transcriptId}
disabled={isRecording}
></FileUploadButton>
{!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()} {timeLabel()}
</div> </Box>
</div> </Box>
</Flex>
{hasRecorded && (
<>
<button
className={`${
isPlaying
? "bg-orange-400 hover:bg-orange-500 focus-visible:bg-orange-500"
: "bg-green-400 hover:bg-green-500 focus-visible:bg-green-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
id="play-btn"
onClick={handlePlayClick}
>
{isPlaying ? "Pause" : "Play"}
</button>
</>
)}
{!hasRecorded && (
<>
<button
className={`${
isRecording
? "bg-red-400 hover:bg-red-500 focus-visible:bg-red-500"
: "bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
onClick={handleRecClick}
disabled={isPlaying}
>
{isRecording ? "Stop" : "Record"}
</button>
<FileUploadButton
transcriptId={props.transcriptId}
></FileUploadButton>
{!isRecording && (
<button
className={`${
isRecording
? "bg-red-400 hover:bg-red-500 focus-visible:bg-red-500"
: "bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
onClick={handleRecordTabClick}
>
Record
<br />a tab
</button>
)}
{props.audioDevices && props.audioDevices?.length > 0 && deviceId && (
<>
<button
className="text-center text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg focus-visible:outline outline-blue-400"
onClick={() => setShowDevices((prev) => !prev)}
>
<FontAwesomeIcon icon={faMicrophone} className="h-5 w-auto" />
</button>
<div
className={`absolute z-20 bottom-[-1rem] right-0 bg-white rounded ${
showDevices ? "visible" : "invisible"
}`}
>
<AudioInputsDropdown
setDeviceId={setDeviceId}
audioDevices={props.audioDevices}
disabled={recordStarted}
hide={() => setShowDevices(false)}
deviceId={deviceId}
/>
</div>
</>
)}
</>
)}
</div>
); );
} }

View File

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

View File

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

View File

@@ -1,19 +1,10 @@
import React, { useState, useRef, useEffect, use } from "react"; import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../domainContext"; import { featureEnabled } from "../domainContext";
import { useFiefUserinfo } from "@fief/fief/nextjs/react"; import { Button, Flex, Input, Text } from "@chakra-ui/react";
import SelectSearch from "react-select-search"; import QRCode from "react-qr-code";
import "react-select-search/style.css";
import "../../styles/button.css";
import "../../styles/form.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { UpdateTranscript } from "../../api";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import useApi from "../../lib/useApi";
type ShareLinkProps = { type ShareLinkProps = {
transcriptId: string; transcriptId: string;
userId: string | null;
shareMode: ShareMode;
}; };
const ShareLink = (props: ShareLinkProps) => { const ShareLink = (props: ShareLinkProps) => {
@@ -21,20 +12,12 @@ const ShareLink = (props: ShareLinkProps) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [currentUrl, setCurrentUrl] = useState<string>(""); const [currentUrl, setCurrentUrl] = useState<string>("");
const requireLogin = featureEnabled("requireLogin"); const requireLogin = featureEnabled("requireLogin");
const [isOwner, setIsOwner] = useState(false); const privacyEnabled = featureEnabled("privacy");
const [shareMode, setShareMode] = useState<ShareMode>(props.shareMode);
const [shareLoading, setShareLoading] = useState(false);
const userinfo = useFiefUserinfo();
const api = useApi();
useEffect(() => { useEffect(() => {
setCurrentUrl(window.location.href); setCurrentUrl(window.location.href);
}, []); }, []);
useEffect(() => {
setIsOwner(!!(requireLogin && userinfo?.sub === props.userId));
}, [userinfo, props.userId]);
const handleCopyClick = () => { const handleCopyClick = () => {
if (inputRef.current) { if (inputRef.current) {
let text_to_copy = inputRef.current.value; let text_to_copy = inputRef.current.value;
@@ -48,105 +31,43 @@ const ShareLink = (props: ShareLinkProps) => {
} }
}; };
const updateShareMode = async (selectedShareMode: string) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode),
};
const updatedTranscript = await api.v1TranscriptUpdate(
props.transcriptId,
requestBody,
);
setShareMode(toShareMode(updatedTranscript.share_mode));
setShareLoading(false);
};
const privacyEnabled = featureEnabled("privacy");
return ( return (
<div <>
className="p-2 md:p-4 rounded"
style={{ background: "rgba(96, 165, 250, 0.2)" }}
>
{requireLogin && (
<div className="text-sm mb-2">
{shareMode === "private" && (
<p>This transcript is private and can only be accessed by you.</p>
)}
{shareMode === "semi-private" && (
<p>
This transcript is secure. Only authenticated users can access it.
</p>
)}
{shareMode === "public" && (
<p>This transcript is public. Everyone can access it.</p>
)}
{isOwner && api && (
<div className="relative">
<SelectSearch
className="select-search--top select-search"
options={[
{ name: "Private", value: "private" },
{ name: "Secure", value: "semi-private" },
{ name: "Public", value: "public" },
]}
value={shareMode?.toString()}
onChange={updateShareMode}
closeOnSelect={true}
/>
{shareLoading && (
<div className="h-4 w-4 absolute top-1/3 right-3 z-10">
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-4 w-4"
/>
</div>
)}
</div>
)}
</div>
)}
{!requireLogin && ( {!requireLogin && (
<> <>
{privacyEnabled ? ( {privacyEnabled ? (
<p className="text-sm mb-2"> <Text>
Share this link to grant others access to this page. The link Share this link to grant others access to this page. The link
includes the full audio recording and is valid for the next 7 includes the full audio recording and is valid for the next 7
days. days.
</p> </Text>
) : ( ) : (
<p className="text-sm mb-2"> <Text>
Share this link to allow others to view this page and listen to Share this link to allow others to view this page and listen to
the full audio recording. the full audio recording.
</p> </Text>
)} )}
</> </>
)} )}
<div className="flex items-center"> <Flex align={"center"}>
<input <QRCode
value={`${location.origin}/transcripts/${props.transcriptId}`}
level="L"
size={98}
/>
<Input
type="text" type="text"
readOnly readOnly
value={currentUrl} value={currentUrl}
ref={inputRef} ref={inputRef}
onChange={() => {}} onChange={() => {}}
className="border rounded-lg md:rounded-xl p-2 flex-grow flex-shrink overflow-auto mr-2 text-sm bg-slate-100 outline-slate-400" mx="2"
/> />
<button <Button onClick={handleCopyClick} colorScheme="blue">
onClick={handleCopyClick}
className={
(isCopied ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2"
}
style={{ minHeight: "38px" }}
>
{isCopied ? "Copied!" : "Copy"} {isCopied ? "Copied!" : "Copy"}
</button> </Button>
</div> </Flex>
</div> </>
); );
}; };

View File

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

View File

@@ -1,14 +1,20 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faChevronDown,
} from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../../lib/time"; import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom"; import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes"; import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils"; import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants"; import useParticipants from "./useParticipants";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Text,
} from "@chakra-ui/react";
import { featureEnabled } from "../domainContext";
type TopicListProps = { type TopicListProps = {
topics: Topic[]; topics: Topic[];
@@ -18,6 +24,8 @@ type TopicListProps = {
]; ];
autoscroll: boolean; autoscroll: boolean;
transcriptId: string; transcriptId: string;
status: string;
currentTranscriptText: any;
}; };
export function TopicList({ export function TopicList({
@@ -25,27 +33,37 @@ export function TopicList({
useActiveTopic, useActiveTopic,
autoscroll, autoscroll,
transcriptId, transcriptId,
status,
currentTranscriptText,
}: TopicListProps) { }: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic; const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true); const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
const participants = useParticipants(transcriptId); const participants = useParticipants(transcriptId);
useEffect(() => { const scrollToTopic = () => {
if (autoscroll && autoscrollEnabled) scrollToBottom(); const topicDiv = document.getElementById(
}, [topics.length]); `accordion-button-topic-${activeTopic?.id}`
);
const scrollToBottom = () => { setTimeout(() => {
const topicsDiv = document.getElementById("topics-div"); topicDiv?.scrollIntoView({
behavior: "smooth",
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight; block: "start",
inline: "nearest",
});
}, 200);
}; };
useEffect(() => {
if (activeTopic) scrollToTopic();
}, [activeTopic]);
// scroll top is not rounded, heights are, so exact match won't work. // 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 // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const toggleScroll = (element) => { const toggleScroll = (element) => {
const bottom = const bottom =
Math.abs( Math.abs(
element.scrollHeight - element.clientHeight - element.scrollTop, element.scrollHeight - element.clientHeight - element.scrollTop
) < 2 || element.scrollHeight == element.clientHeight; ) < 2 || element.scrollHeight == element.clientHeight;
if (!bottom && autoscrollEnabled) { if (!bottom && autoscrollEnabled) {
setAutoscrollEnabled(false); setAutoscrollEnabled(false);
@@ -59,108 +77,187 @@ export function TopicList({
useEffect(() => { useEffect(() => {
if (autoscroll) { if (autoscroll) {
const topicsDiv = document.getElementById("topics-div"); const topicsDiv = document.getElementById("scroll-div");
topicsDiv && toggleScroll(topicsDiv); topicsDiv && toggleScroll(topicsDiv);
} }
}, [activeTopic, autoscroll]); }, [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) => { const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return; if (!participants.response) return;
return ( return (
participants.response.find( participants.response.find(
(participant) => participant.speaker == speakerNumber, (participant) => participant.speaker == speakerNumber
)?.name || `Speaker ${speakerNumber}` )?.name || `Speaker ${speakerNumber}`
); );
}; };
const requireLogin = featureEnabled("requireLogin");
useEffect(() => {
setActiveTopic(topics[topics.length - 1]);
}, [topics]);
useEffect(() => {
if (activeTopic && currentTranscriptText) setActiveTopic(null);
}, [activeTopic, currentTranscriptText]);
return ( return (
<section className="relative w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-1 sm:p-2 md:px-4 flex flex-col justify-center align-center"> <Flex
{topics.length > 0 ? ( position={"relative"}
<> w={"100%"}
<h2 className="ml-2 md:text-lg font-bold mb-2">Topics</h2> h={"100%"}
flexDirection={"column"}
justify={"center"}
align={"center"}
flexShrink={0}
>
{autoscroll && (
<ScrollToBottom
visible={!autoscrollEnabled}
handleScrollBottom={scrollToBottom}
/>
)}
{autoscroll && ( <Box
<ScrollToBottom id="scroll-div"
visible={!autoscrollEnabled} overflowY={"auto"}
handleScrollBottom={scrollToBottom} h={"100%"}
/> onScroll={handleScroll}
)} width="full"
padding={2}
<div >
id="topics-div" {topics.length > 0 && (
className="overflow-y-auto h-full" <Accordion
onScroll={handleScroll} index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
variant="custom"
allowToggle
> >
{topics.map((topic, index) => ( {topics.map((topic, index) => (
<button <AccordionItem
key={index} key={index}
className="rounded-none border-solid border-0 border-bluegrey border-b last:border-none last:rounded-b-lg p-2 hover:bg-blue-400/20 focus-visible:bg-blue-400/20 text-left block w-full" background={{
onClick={() => base: "light",
setActiveTopic(activeTopic?.id == topic.id ? null : topic) hover: "gray.100",
} focus: "gray.100",
}}
id={`topic-${topic.id}`}
> >
<div className="w-full flex justify-between items-center rounded-lg md:rounded-xl xs:text-base sm:text-lg md:text-xl font-bold leading-tight"> <Flex dir="row" letterSpacing={".2"}>
<p> <AccordionButton
<span className="font-light font-mono text-slate-500 text-base md:text-lg"> onClick={() => {
[{formatTime(topic.timestamp)}]&nbsp; setActiveTopic(
</span> activeTopic?.id == topic.id ? null : topic
<span>{topic.title}</span> );
</p> }}
<FontAwesomeIcon >
className="transform transition-transform duration-200 ml-2" <AccordionIcon />
icon={ <Box as="span" textAlign="left" ml="1">
activeTopic?.id == topic.id {topic.title}{" "}
? faChevronDown <Text
: faChevronRight as="span"
} color="gray.500"
/> fontSize="sm"
</div> fontWeight="bold"
{activeTopic?.id == topic.id && ( >
<div className="p-2"> &nbsp;[{formatTime(topic.timestamp)}]&nbsp;-&nbsp;[
{topic.segments ? ( {formatTime(topic.timestamp + (topic.duration || 0))}]
<> </Text>
{topic.segments.map((segment, index: number) => ( </Box>
<p </AccordionButton>
key={index} </Flex>
className="text-left text-slate-500 text-sm md:text-base" <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"}
> >
<span className="font-mono text-slate-500"> [{formatTime(segment.start)}]
[{formatTime(segment.start)}] </Text>
</span> <Text
<span as="span"
className="font-bold text-slate-500" fontWeight={"bold"}
style={{ fontSize={"sm"}
color: generateHighContrastColor( color={generateHighContrastColor(
`Speaker ${segment.speaker}`, `Speaker ${segment.speaker}`,
[96, 165, 250], [96, 165, 250]
), )}
}} >
> {" "}
{" "} {getSpeakerName(segment.speaker)}:
{getSpeakerName(segment.speaker)}: </Text>{" "}
</span>{" "} <span>{segment.text}</span>
<span>{segment.text}</span> </Text>
</p> ))}
))} </>
</> ) : (
) : ( <>{topic.transcript}</>
<>{topic.transcript}</> )}
)} </AccordionPanel>
</div> </AccordionItem>
)}
</button>
))} ))}
</div> </Accordion>
</> )}
) : (
<div className="text-center text-gray-500"> {status == "recording" && (
Discussion topics will appear here after you start recording. <Box textAlign={"center"}>
<br /> <Text>{currentTranscriptText}</Text>
It may take up to 5 minutes of conversation for the first topic to </Box>
appear. )}
</div> {(status == "recording" || status == "idle") &&
)} currentTranscriptText.length == 0 &&
</section> topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>
Discussion transcript will appear here after you start
recording.
</Text>
<Text>
It may take up to 5 minutes of conversation to first appear.
</Text>
</Box>
)}
{status == "processing" && (
<Box textAlign={"center"} textColor="gray">
<Text>We are processing the recording, please wait.</Text>
{!requireLogin && (
<span>
Please do not navigate away from the page during this time.
</span>
)}
</Box>
)}
{status == "ended" && topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>Recording has ended without topics being found.</Text>
</Box>
)}
{status == "error" && (
<Box textAlign={"center"} textColor="gray">
<Text>There was an error processing your recording</Text>
</Box>
)}
</Box>
</Flex>
); );
} }

View File

@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { UpdateTranscript } from "../../api"; import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
import { Heading, IconButton, Input } from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
type TranscriptTitle = { type TranscriptTitle = {
title: string; title: string;
@@ -19,7 +21,6 @@ const TranscriptTitle = (props: TranscriptTitle) => {
const requestBody: UpdateTranscript = { const requestBody: UpdateTranscript = {
title: newTitle, title: newTitle,
}; };
const api = useApi();
const updatedTranscript = await api?.v1TranscriptUpdate( const updatedTranscript = await api?.v1TranscriptUpdate(
transcriptId, transcriptId,
requestBody, requestBody,
@@ -46,6 +47,12 @@ const TranscriptTitle = (props: TranscriptTitle) => {
} }
}; };
const handleBlur = () => {
if (displayedTitle !== preEditTitle) {
updateTitle(displayedTitle, props.transcriptId);
}
setIsEditing(false);
};
const handleChange = (e) => { const handleChange = (e) => {
setDisplayedTitle(e.target.value); setDisplayedTitle(e.target.value);
}; };
@@ -63,21 +70,36 @@ const TranscriptTitle = (props: TranscriptTitle) => {
return ( return (
<> <>
{isEditing ? ( {isEditing ? (
<input <Input
type="text" type="text"
value={displayedTitle} value={displayedTitle}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus autoFocus
className="text-2xl lg:text-4xl font-extrabold text-center mb-4 w-full border-none bg-transparent overflow-hidden h-[fit-content]" 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]"
/> />
) : ( ) : (
<h2 <>
className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer" <Heading
onClick={handleTitleClick} // className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer"
> onClick={handleTitleClick}
{displayedTitle} cursor={"pointer"}
</h2> size={"lg"}
noOfLines={1}
>
{displayedTitle}
</Heading>
<IconButton
icon={<FaPen />}
aria-label="Edit Transcript Title"
onClick={handleTitleClick}
fontSize={"15px"}
/>
</>
)} )}
</> </>
); );

View File

@@ -20,6 +20,7 @@ const useTranscriptList = (page: number): TranscriptList => {
const [refetchCount, setRefetchCount] = useState(0); const [refetchCount, setRefetchCount] = useState(0);
const refetch = () => { const refetch = () => {
setLoading(true);
setRefetchCount(refetchCount + 1); setRefetchCount(refetchCount + 1);
}; };

View File

@@ -6,8 +6,9 @@ import { AudioWaveform, GetTranscriptSegmentTopic } from "../../api";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
export type UseWebSockets = { export type UseWebSockets = {
transcriptText: string; transcriptTextLive: string;
translateText: string; translateText: string;
accumulatedText: string;
title: string; title: string;
topics: Topic[]; topics: Topic[];
finalSummary: FinalSummary; finalSummary: FinalSummary;
@@ -17,7 +18,7 @@ export type UseWebSockets = {
}; };
export const useWebSockets = (transcriptId: string | null): UseWebSockets => { export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [transcriptText, setTranscriptText] = useState<string>(""); const [transcriptTextLive, setTranscriptTextLive] = useState<string>("");
const [translateText, setTranslateText] = useState<string>(""); const [translateText, setTranslateText] = useState<string>("");
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [textQueue, setTextQueue] = useState<string[]>([]); const [textQueue, setTextQueue] = useState<string[]>([]);
@@ -29,12 +30,14 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [finalSummary, setFinalSummary] = useState<FinalSummary>({ const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "", summary: "",
}); });
const [status, setStatus] = useState<Status>({ value: "initial" }); const [status, setStatus] = useState<Status>({ value: "" });
const { setError } = useError(); const { setError } = useError();
const { websocket_url } = useContext(DomainContext); const { websocket_url } = useContext(DomainContext);
const api = useApi(); const api = useApi();
const [accumulatedText, setAccumulatedText] = useState<string>("");
useEffect(() => { useEffect(() => {
if (isProcessing || textQueue.length === 0) { if (isProcessing || textQueue.length === 0) {
return; return;
@@ -42,13 +45,12 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
setIsProcessing(true); setIsProcessing(true);
const text = textQueue[0]; const text = textQueue[0];
setTranscriptText(text); setTranscriptTextLive(text);
setTranslateText(translationQueue[0]); setTranslateText(translationQueue[0]);
const WPM_READING = 200 + textQueue.length * 10; // words per minute to read const WPM_READING = 200 + textQueue.length * 10; // words per minute to read
const wordCount = text.split(/\s+/).length; const wordCount = text.split(/\s+/).length;
const delay = (wordCount / WPM_READING) * 60 * 1000; const delay = (wordCount / WPM_READING) * 60 * 1000;
console.log(`displaying "${text}" for ${delay}ms`);
setTimeout(() => { setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
setTextQueue((prevQueue) => prevQueue.slice(1)); setTextQueue((prevQueue) => prevQueue.slice(1));
@@ -92,7 +94,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}, },
]; ];
setTranscriptText("Lorem Ipsum"); setTranscriptTextLive("Lorem Ipsum");
setTopics([ setTopics([
{ {
id: "1", id: "1",
@@ -190,9 +192,13 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
setFinalSummary({ summary: "This is the final summary" }); setFinalSummary({ summary: "This is the final summary" });
} }
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") { if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
setTranscriptText( setTranscriptTextLive(
"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",
); );
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: "recording" });
setTopics([ setTopics([
{ {
id: "1", id: "1",
@@ -333,6 +339,8 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
console.debug("TRANSCRIPT event:", newText); console.debug("TRANSCRIPT event:", newText);
setTextQueue((prevQueue) => [...prevQueue, newText]); setTextQueue((prevQueue) => [...prevQueue, newText]);
setTranslationQueue((prevQueue) => [...prevQueue, newTranslation]); setTranslationQueue((prevQueue) => [...prevQueue, newTranslation]);
setAccumulatedText((prevText) => prevText + " " + newText);
break; break;
case "TOPIC": case "TOPIC":
@@ -345,6 +353,10 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
prevTopics[index] = topic; prevTopics[index] = topic;
return prevTopics; return prevTopics;
} }
setAccumulatedText((prevText) =>
prevText.slice(topic.transcript.length),
);
return [...prevTopics, topic]; return [...prevTopics, topic];
}); });
console.debug("TOPIC event:", message.data); console.debug("TOPIC event:", message.data);
@@ -419,18 +431,18 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
break; break;
case 1005: // Closure by client FF case 1005: // Closure by client FF
break; break;
case 1001: // Navigate away
break;
default: default:
setError( setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`), new Error(`WebSocket closed unexpectedly with code: ${event.code}`),
"Disconnected", "Disconnected from the server. Please refresh the page.",
); );
console.log( console.log(
"Socket is closed. Reconnect will be attempted in 1 second.", "Socket is closed. Reconnect will be attempted in 1 second.",
event.reason, event.reason,
); );
setTimeout(function () { // todo handle reconnect with socket.io
ws = new WebSocket(url);
}, 1000);
} }
}; };
@@ -440,8 +452,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}, [transcriptId, !api]); }, [transcriptId, !api]);
return { return {
transcriptText, transcriptTextLive,
translateText, translateText,
accumulatedText,
topics, topics,
finalSummary, finalSummary,
title, title,

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ChakraProvider } from "@chakra-ui/react"; import { ChakraProvider } from "@chakra-ui/react";
import theme from "./theme"; import theme from "./styles/theme";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return <ChakraProvider theme={theme}>{children}</ChakraProvider>; return <ChakraProvider theme={theme}>{children}</ChakraProvider>;

View File

@@ -0,0 +1,14 @@
import { Icon } from "@chakra-ui/react";
export default function PauseIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M11.514 5.5C11.514 4.11929 10.3947 3 9.01404 3C7.63333 3 6.51404 4.11929 6.51404 5.5V24.5C6.51404 25.8807 7.63333 27 9.01404 27C10.3947 27 11.514 25.8807 11.514 24.5L11.514 5.5ZM23.486 5.5C23.486 4.11929 22.3667 3 20.986 3C19.6053 3 18.486 4.11929 18.486 5.5L18.486 24.5C18.486 25.8807 19.6053 27 20.986 27C22.3667 27 23.486 25.8807 23.486 24.5V5.5Z"
/>
</Icon>
);
}

View File

@@ -0,0 +1,12 @@
import { Icon } from "@chakra-ui/react";
export default function PlayIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
d="M27 13.2679C28.3333 14.0377 28.3333 15.9622 27 16.732L10.5 26.2583C9.16666 27.0281 7.5 26.0659 7.5 24.5263L7.5 5.47372C7.5 3.93412 9.16667 2.97187 10.5 3.74167L27 13.2679Z"
/>
</Icon>
);
}

View File

@@ -0,0 +1,9 @@
import { Icon } from "@chakra-ui/react";
export default function StopRecordIcon(props) {
return (
<Icon viewBox="0 0 20 20" {...props}>
<rect width="20" height="20" rx="1" fill="currentColor" />
</Icon>
);
}

View File

@@ -1,20 +1,26 @@
import { theme } from "@chakra-ui/react";
export const waveSurferStyles = { export const waveSurferStyles = {
playerSettings: { playerSettings: {
waveColor: "#777", waveColor: theme.colors.blue[500],
progressColor: "#222", progressColor: theme.colors.blue[700],
cursorColor: "OrangeRed", cursorColor: theme.colors.red[500],
hideScrollbar: true,
autoScroll: false,
autoCenter: false,
barWidth: 3,
barGap: 2,
cursorWidth: 2,
}, },
playerStyle: { playerStyle: {
cursor: "pointer", cursor: "pointer",
backgroundColor: "RGB(240 240 240)",
borderRadius: "15px",
}, },
marker: ` marker: `
border-left: solid 1px orange; border-left: solid 1px orange;
padding: 0 2px 0 5px; padding: 0 2px 0 5px;
font-size: 0.7rem; font-size: 0.7rem;
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
top: 0;
width: 100px; width: 100px;
max-width: fit-content; max-width: fit-content;
cursor: pointer; cursor: pointer;
@@ -25,5 +31,5 @@ export const waveSurferStyles = {
transition: width 100ms linear; transition: width 100ms linear;
z-index: 0; z-index: 0;
`, `,
markerHover: { backgroundColor: "orange" }, markerHover: { backgroundColor: theme.colors.gray[200] },
}; };

66
www/app/styles/theme.ts Normal file
View File

@@ -0,0 +1,66 @@
// 1. Import `extendTheme`
import { extendTheme } from "@chakra-ui/react";
import { accordionAnatomy } from "@chakra-ui/anatomy";
import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react";
const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(accordionAnatomy.keys);
const custom = definePartsStyle({
container: {
border: "0",
borderRadius: "8px",
backgroundColor: "white",
mb: 2,
mr: 2,
},
panel: {
pl: 8,
pb: 0,
},
button: {
justifyContent: "flex-start",
pl: 2,
},
});
const accordionTheme = defineMultiStyleConfig({
variants: { custom },
});
export const colors = {
blue: {
primary: "#3158E2",
500: "#3158E2",
light: "#B1CBFF",
200: "#B1CBFF",
dark: "#0E1B48",
900: "#0E1B48",
},
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
},
light: "#FFFFFF",
dark: "#0C0D0E",
};
const theme = extendTheme({
colors,
components: {
Accordion: accordionTheme,
},
});
export default theme;

View File

@@ -1,34 +0,0 @@
// 1. Import `extendTheme`
import { extendTheme } from "@chakra-ui/react";
// 2. Call `extendTheme` and pass your custom values
const theme = extendTheme({
colors: {
blue: {
primary: "#3158E2",
500: "#3158E2",
light: "#B1CBFF",
200: "#B1CBFF",
dark: "#0E1B48",
900: "#0E1B48",
},
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
},
light: "#FFFFFF",
dark: "#0C0D0E",
},
});
export default theme;