mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
@@ -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();
|
||||||
|
|||||||
150
www/app/[domain]/transcripts/[transcriptId]/finalSummary.tsx
Normal file
150
www/app/[domain]/transcripts/[transcriptId]/finalSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)" }}
|
||||||
|
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 || "Unamed Transcript"}
|
||||||
|
transcriptId={transcriptId}
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
<TopicList
|
<TopicList
|
||||||
topics={topics.topics || []}
|
topics={topics.topics || []}
|
||||||
useActiveTopic={useActiveTopic}
|
useActiveTopic={useActiveTopic}
|
||||||
autoscroll={false}
|
autoscroll={false}
|
||||||
transcriptId={transcriptId}
|
transcriptId={transcriptId}
|
||||||
|
status={transcript.response?.status}
|
||||||
|
currentTranscriptText=""
|
||||||
/>
|
/>
|
||||||
|
{transcript.response && topics.topics ? (
|
||||||
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
|
<>
|
||||||
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full">
|
|
||||||
{transcript.response.long_summary ? (
|
|
||||||
<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>
|
||||||
|
</Grid>
|
||||||
<section className="flex items-center">
|
</>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
className={`w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4`}
|
|
||||||
>
|
|
||||||
{!recordedTime ? (
|
|
||||||
<>
|
|
||||||
{transcriptStarted && (
|
|
||||||
<h2 className="md:text-lg font-bold">Transcription</h2>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col justify-center align center text-center h-full">
|
|
||||||
<div className="py-2 h-auto">
|
|
||||||
{!transcriptStarted ? (
|
{!transcriptStarted ? (
|
||||||
<div className="text-center text-gray-500">
|
<Box textAlign={"center"} textColor="gray">
|
||||||
The conversation transcript will appear here shortly after
|
<Text>
|
||||||
you start recording.
|
The conversation transcript will appear here shortly after you
|
||||||
</div>
|
start recording.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
status === "recording" && (
|
||||||
<LiveTrancription
|
<LiveTrancription
|
||||||
text={webSockets.transcriptText}
|
text={webSockets.transcriptTextLive}
|
||||||
translateText={webSockets.translateText}
|
translateText={webSockets.translateText}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Grid>
|
||||||
</>
|
</Grid>
|
||||||
) : (
|
|
||||||
<div className="flex flex-col justify-center align center text-center h-full text-gray-500">
|
|
||||||
<div className="p-2 md:p-4">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faGear}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disconnected && <DisconnectedIndicator />}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
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();
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
{isPlaying ? "Pause" : "Play"}
|
{timeLabel()}
|
||||||
</button>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
|
||||||
<div className="inline-block bg-red-500 rounded-full w-2 h-2 my-auto mr-1 animate-ping"></div>
|
|
||||||
)}
|
|
||||||
{timeLabel()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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}
|
onClick={handleRecClick}
|
||||||
disabled={isPlaying}
|
/>
|
||||||
>
|
|
||||||
{isRecording ? "Stop" : "Record"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<FileUploadButton
|
<FileUploadButton
|
||||||
transcriptId={props.transcriptId}
|
transcriptId={props.transcriptId}
|
||||||
|
disabled={isRecording}
|
||||||
></FileUploadButton>
|
></FileUploadButton>
|
||||||
|
{!isRecording && (window as any).chrome && (
|
||||||
{!isRecording && (
|
<IconButton
|
||||||
<button
|
aria-label={"Record Tab"}
|
||||||
className={`${
|
icon={<LuScreenShare />}
|
||||||
isRecording
|
variant={"ghost"}
|
||||||
? "bg-red-400 hover:bg-red-500 focus-visible:bg-red-500"
|
colorScheme={"blue"}
|
||||||
: "bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500"
|
disabled={isRecording}
|
||||||
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
|
mr={2}
|
||||||
onClick={handleRecordTabClick}
|
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
149
www/app/[domain]/transcripts/shareAndPrivacy.tsx
Normal file
149
www/app/[domain]/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(
|
||||||
|
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/[domain]/transcripts/shareCopy.tsx
Normal file
62
www/app/[domain]/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
36
www/app/[domain]/transcripts/shareZulip.tsx
Normal file
36
www/app/[domain]/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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,27 +77,51 @@ 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}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const requireLogin = featureEnabled("requireLogin");
|
||||||
<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">
|
|
||||||
{topics.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<h2 className="ml-2 md:text-lg font-bold mb-2">Topics</h2>
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTopic(topics[topics.length - 1]);
|
||||||
|
}, [topics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTopic && currentTranscriptText) setActiveTopic(null);
|
||||||
|
}, [activeTopic, currentTranscriptText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
position={"relative"}
|
||||||
|
w={"100%"}
|
||||||
|
h={"100%"}
|
||||||
|
flexDirection={"column"}
|
||||||
|
justify={"center"}
|
||||||
|
align={"center"}
|
||||||
|
flexShrink={0}
|
||||||
|
>
|
||||||
{autoscroll && (
|
{autoscroll && (
|
||||||
<ScrollToBottom
|
<ScrollToBottom
|
||||||
visible={!autoscrollEnabled}
|
visible={!autoscrollEnabled}
|
||||||
@@ -87,80 +129,135 @@ export function TopicList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<Box
|
||||||
id="topics-div"
|
id="scroll-div"
|
||||||
className="overflow-y-auto h-full"
|
overflowY={"auto"}
|
||||||
|
h={"100%"}
|
||||||
onScroll={handleScroll}
|
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) => (
|
{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)}]
|
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">
|
[{formatTime(topic.timestamp)}] - [
|
||||||
|
{formatTime(topic.timestamp + (topic.duration || 0))}]
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</AccordionButton>
|
||||||
|
</Flex>
|
||||||
|
<AccordionPanel>
|
||||||
{topic.segments ? (
|
{topic.segments ? (
|
||||||
<>
|
<>
|
||||||
{topic.segments.map((segment, index: number) => (
|
{topic.segments.map((segment, index: number) => (
|
||||||
<p
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
className="text-left text-slate-500 text-sm md:text-base"
|
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)}]
|
||||||
</span>
|
</Text>
|
||||||
<span
|
<Text
|
||||||
className="font-bold text-slate-500"
|
as="span"
|
||||||
style={{
|
fontWeight={"bold"}
|
||||||
color: generateHighContrastColor(
|
fontSize={"sm"}
|
||||||
|
color={generateHighContrastColor(
|
||||||
`Speaker ${segment.speaker}`,
|
`Speaker ${segment.speaker}`,
|
||||||
[96, 165, 250],
|
[96, 165, 250]
|
||||||
),
|
)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
{getSpeakerName(segment.speaker)}:
|
{getSpeakerName(segment.speaker)}:
|
||||||
</span>{" "}
|
</Text>{" "}
|
||||||
<span>{segment.text}</span>
|
<span>{segment.text}</span>
|
||||||
</p>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>{topic.transcript}</>
|
<>{topic.transcript}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AccordionPanel>
|
||||||
)}
|
</AccordionItem>
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Accordion>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-gray-500">
|
|
||||||
Discussion topics will appear here after you start recording.
|
|
||||||
<br />
|
|
||||||
It may take up to 5 minutes of conversation for the first topic to
|
|
||||||
appear.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
{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>
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer"
|
||||||
onClick={handleTitleClick}
|
onClick={handleTitleClick}
|
||||||
|
cursor={"pointer"}
|
||||||
|
size={"lg"}
|
||||||
|
noOfLines={1}
|
||||||
>
|
>
|
||||||
{displayedTitle}
|
{displayedTitle}
|
||||||
</h2>
|
</Heading>
|
||||||
|
<IconButton
|
||||||
|
icon={<FaPen />}
|
||||||
|
aria-label="Edit Transcript Title"
|
||||||
|
onClick={handleTitleClick}
|
||||||
|
fontSize={"15px"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
14
www/app/styles/icons/pause.tsx
Normal file
14
www/app/styles/icons/pause.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
www/app/styles/icons/play.tsx
Normal file
12
www/app/styles/icons/play.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
www/app/styles/icons/stopRecord.tsx
Normal file
9
www/app/styles/icons/stopRecord.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
66
www/app/styles/theme.ts
Normal 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;
|
||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user