mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-23 05:39:05 +00:00
Remove domain segment
This commit is contained in:
123
www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
Normal file
123
www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import TopicHeader from "./topicHeader";
|
||||
import TopicWords from "./topicWords";
|
||||
import TopicPlayer from "./topicPlayer";
|
||||
import useParticipants from "../../useParticipants";
|
||||
import useTopicWithWords from "../../useTopicWithWords";
|
||||
import ParticipantList from "./participantList";
|
||||
import { GetTranscriptTopic } from "../../../../api";
|
||||
import { SelectedText, selectedTextIsTimeSlice } from "./types";
|
||||
import useApi from "../../../../lib/useApi";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useError } from "../../../../(errors)/errorContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Grid } from "@chakra-ui/react";
|
||||
|
||||
export type TranscriptCorrect = {
|
||||
params: {
|
||||
transcriptId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function TranscriptCorrect({
|
||||
params: { transcriptId },
|
||||
}: TranscriptCorrect) {
|
||||
const api = useApi();
|
||||
const transcript = useTranscript(transcriptId);
|
||||
const stateCurrentTopic = useState<GetTranscriptTopic>();
|
||||
const [currentTopic, _sct] = stateCurrentTopic;
|
||||
const stateSelectedText = useState<SelectedText>();
|
||||
const [selectedText, _sst] = stateSelectedText;
|
||||
const topicWithWords = useTopicWithWords(currentTopic?.id, transcriptId);
|
||||
const participants = useParticipants(transcriptId);
|
||||
const { setError } = useError();
|
||||
const router = useRouter();
|
||||
|
||||
const markAsDone = () => {
|
||||
if (transcript.response && !transcript.response.reviewed) {
|
||||
api
|
||||
?.v1TranscriptUpdate({ transcriptId, requestBody: { reviewed: true } })
|
||||
.then(() => {
|
||||
router.push(`/transcripts/${transcriptId}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "Error marking as done");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateRows="auto minmax(0, 1fr)"
|
||||
h="100%"
|
||||
maxW={{ lg: "container.lg" }}
|
||||
mx="auto"
|
||||
minW={{ base: "100%", lg: "container.lg" }}
|
||||
>
|
||||
<Box display="flex" flexDir="column" minW="100%" mb={{ base: 4, lg: 10 }}>
|
||||
<TopicHeader
|
||||
minW="100%"
|
||||
stateCurrentTopic={stateCurrentTopic}
|
||||
transcriptId={transcriptId}
|
||||
topicWithWordsLoading={topicWithWords.loading}
|
||||
/>
|
||||
|
||||
<TopicPlayer
|
||||
transcriptId={transcriptId}
|
||||
selectedTime={
|
||||
selectedTextIsTimeSlice(selectedText) ? selectedText : undefined
|
||||
}
|
||||
topicTime={
|
||||
currentTopic
|
||||
? {
|
||||
start: currentTopic?.timestamp,
|
||||
end: currentTopic?.timestamp + (currentTopic?.duration || 0),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "minmax(0, 1fr)",
|
||||
md: "4fr 3fr",
|
||||
lg: "2fr 1fr",
|
||||
}}
|
||||
templateRows={{
|
||||
base: "repeat(2, minmax(0, 1fr)) auto",
|
||||
md: "minmax(0, 1fr)",
|
||||
}}
|
||||
gap={{ base: "2", md: "4", lg: "4" }}
|
||||
h="100%"
|
||||
maxH="100%"
|
||||
w="100%"
|
||||
>
|
||||
<TopicWords
|
||||
stateSelectedText={stateSelectedText}
|
||||
participants={participants}
|
||||
topicWithWords={topicWithWords}
|
||||
mb={{ md: "-3rem" }}
|
||||
/>
|
||||
<ParticipantList
|
||||
{...{
|
||||
transcriptId,
|
||||
participants,
|
||||
topicWithWords,
|
||||
stateSelectedText,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{transcript.response && !transcript.response?.reviewed && (
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="p-2 px-4 rounded bg-green-400"
|
||||
onClick={markAsDone}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { faArrowTurnDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { Participant } from "../../../../api";
|
||||
import useApi from "../../../../lib/useApi";
|
||||
import { UseParticipants } from "../../useParticipants";
|
||||
import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./types";
|
||||
import { useError } from "../../../../(errors)/errorContext";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Text,
|
||||
UnorderedList,
|
||||
Input,
|
||||
Kbd,
|
||||
Spinner,
|
||||
ListItem,
|
||||
Grid,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
type ParticipantList = {
|
||||
participants: UseParticipants;
|
||||
transcriptId: string;
|
||||
topicWithWords: any;
|
||||
stateSelectedText: any;
|
||||
};
|
||||
const ParticipantList = ({
|
||||
transcriptId,
|
||||
participants,
|
||||
topicWithWords,
|
||||
stateSelectedText,
|
||||
}: ParticipantList) => {
|
||||
const api = useApi();
|
||||
const { setError } = useError();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [participantInput, setParticipantInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedText, setSelectedText] = stateSelectedText;
|
||||
const [selectedParticipant, setSelectedParticipant] = useState<Participant>();
|
||||
const [action, setAction] = useState<
|
||||
"Create" | "Create to rename" | "Create and assign" | "Rename" | null
|
||||
>(null);
|
||||
const [oneMatch, setOneMatch] = useState<Participant>();
|
||||
|
||||
useEffect(() => {
|
||||
if (participants.response) {
|
||||
if (selectedTextIsSpeaker(selectedText)) {
|
||||
inputRef.current?.focus();
|
||||
const participant = participants.response.find(
|
||||
(p) => p.speaker == selectedText,
|
||||
);
|
||||
if (participant) {
|
||||
setParticipantInput(participant.name);
|
||||
setOneMatch(undefined);
|
||||
setSelectedParticipant(participant);
|
||||
setAction("Rename");
|
||||
} else {
|
||||
setSelectedParticipant(participant);
|
||||
setParticipantInput("");
|
||||
setOneMatch(undefined);
|
||||
setAction("Create to rename");
|
||||
}
|
||||
}
|
||||
if (selectedTextIsTimeSlice(selectedText)) {
|
||||
inputRef.current?.focus();
|
||||
setParticipantInput("");
|
||||
setOneMatch(undefined);
|
||||
setAction("Create and assign");
|
||||
setSelectedParticipant(undefined);
|
||||
}
|
||||
|
||||
if (typeof selectedText == "undefined") {
|
||||
inputRef.current?.blur();
|
||||
setSelectedParticipant(undefined);
|
||||
setAction(null);
|
||||
}
|
||||
}
|
||||
}, [selectedText, !participants.response]);
|
||||
|
||||
useEffect(() => {
|
||||
document.onkeyup = (e) => {
|
||||
if (loading || participants.loading || topicWithWords.loading) return;
|
||||
if (e.key === "Enter" && e.ctrlKey) {
|
||||
if (oneMatch) {
|
||||
if (
|
||||
action == "Create and assign" &&
|
||||
selectedTextIsTimeSlice(selectedText)
|
||||
) {
|
||||
assignTo(oneMatch)().catch(() => {});
|
||||
} else if (
|
||||
action == "Create to rename" &&
|
||||
selectedTextIsSpeaker(selectedText)
|
||||
) {
|
||||
mergeSpeaker(selectedText, oneMatch)();
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
doAction();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const onSuccess = () => {
|
||||
topicWithWords.refetch();
|
||||
participants.refetch();
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
setSelectedText(undefined);
|
||||
setSelectedParticipant(undefined);
|
||||
setParticipantInput("");
|
||||
setOneMatch(undefined);
|
||||
inputRef?.current?.blur();
|
||||
};
|
||||
|
||||
const assignTo =
|
||||
(participant) => async (e?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
if (loading || participants.loading || topicWithWords.loading) return;
|
||||
if (!selectedTextIsTimeSlice(selectedText)) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api?.v1TranscriptAssignSpeaker({
|
||||
transcriptId,
|
||||
requestBody: {
|
||||
participant: participant.id,
|
||||
timestamp_from: selectedText.start,
|
||||
timestamp_to: selectedText.end,
|
||||
},
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
setError(error, "There was an error assigning");
|
||||
setLoading(false);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeSpeaker =
|
||||
(speakerFrom, participantTo: Participant) => async () => {
|
||||
if (loading || participants.loading || topicWithWords.loading) return;
|
||||
setLoading(true);
|
||||
if (participantTo.speaker) {
|
||||
try {
|
||||
await api?.v1TranscriptMergeSpeaker({
|
||||
transcriptId,
|
||||
requestBody: {
|
||||
speaker_from: speakerFrom,
|
||||
speaker_to: participantTo.speaker,
|
||||
},
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
setError(error, "There was an error merging");
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await api?.v1TranscriptUpdateParticipant({
|
||||
transcriptId,
|
||||
participantId: participantTo.id,
|
||||
requestBody: { speaker: speakerFrom },
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
setError(error, "There was an error merging (update)");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doAction = async (e?) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (
|
||||
loading ||
|
||||
participants.loading ||
|
||||
topicWithWords.loading ||
|
||||
!participants.response
|
||||
)
|
||||
return;
|
||||
if (action == "Rename" && selectedTextIsSpeaker(selectedText)) {
|
||||
const participant = participants.response.find(
|
||||
(p) => p.speaker == selectedText,
|
||||
);
|
||||
if (participant && participant.name !== participantInput) {
|
||||
setLoading(true);
|
||||
api
|
||||
?.v1TranscriptUpdateParticipant({
|
||||
transcriptId,
|
||||
participantId: participant.id,
|
||||
requestBody: {
|
||||
name: participantInput,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
participants.refetch();
|
||||
setLoading(false);
|
||||
setAction(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "There was an error renaming");
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
action == "Create to rename" &&
|
||||
selectedTextIsSpeaker(selectedText)
|
||||
) {
|
||||
setLoading(true);
|
||||
api
|
||||
?.v1TranscriptAddParticipant({
|
||||
transcriptId,
|
||||
requestBody: {
|
||||
name: participantInput,
|
||||
speaker: selectedText,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
participants.refetch();
|
||||
setParticipantInput("");
|
||||
setOneMatch(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "There was an error creating");
|
||||
setLoading(false);
|
||||
});
|
||||
} else if (
|
||||
action == "Create and assign" &&
|
||||
selectedTextIsTimeSlice(selectedText)
|
||||
) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const participant = await api?.v1TranscriptAddParticipant({
|
||||
transcriptId,
|
||||
requestBody: {
|
||||
name: participantInput,
|
||||
},
|
||||
});
|
||||
setLoading(false);
|
||||
assignTo(participant)().catch(() => {
|
||||
// error and loading are handled by assignTo catch
|
||||
participants.refetch();
|
||||
});
|
||||
} catch (error) {
|
||||
setError(e, "There was an error creating");
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action == "Create") {
|
||||
setLoading(true);
|
||||
api
|
||||
?.v1TranscriptAddParticipant({
|
||||
transcriptId,
|
||||
requestBody: {
|
||||
name: participantInput,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
participants.refetch();
|
||||
setParticipantInput("");
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "There was an error creating");
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteParticipant = (participantId) => (e) => {
|
||||
e.stopPropagation();
|
||||
if (loading || participants.loading || topicWithWords.loading) return;
|
||||
setLoading(true);
|
||||
api
|
||||
?.v1TranscriptDeleteParticipant({ transcriptId, participantId })
|
||||
.then(() => {
|
||||
participants.refetch();
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "There was an error deleting");
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const selectParticipant = (participant) => (e) => {
|
||||
setSelectedParticipant(participant);
|
||||
setSelectedText(participant.speaker);
|
||||
setAction("Rename");
|
||||
setParticipantInput(participant.name);
|
||||
oneMatch && setOneMatch(undefined);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedParticipant(undefined);
|
||||
setSelectedText(undefined);
|
||||
setAction(null);
|
||||
setParticipantInput("");
|
||||
oneMatch && setOneMatch(undefined);
|
||||
};
|
||||
const preventClick = (e) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
};
|
||||
const changeParticipantInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replaceAll(/,|\.| /g, "");
|
||||
setParticipantInput(value);
|
||||
if (
|
||||
value.length > 0 &&
|
||||
participants.response &&
|
||||
(action == "Create and assign" || action == "Create to rename")
|
||||
) {
|
||||
if (
|
||||
participants.response.filter((p) => p.name.startsWith(value)).length ==
|
||||
1
|
||||
) {
|
||||
setOneMatch(
|
||||
participants.response.find((p) => p.name.startsWith(value)),
|
||||
);
|
||||
} else {
|
||||
setOneMatch(undefined);
|
||||
}
|
||||
}
|
||||
if (value.length > 0 && !action) {
|
||||
setAction("Create");
|
||||
}
|
||||
};
|
||||
|
||||
const anyLoading = loading || participants.loading || topicWithWords.loading;
|
||||
|
||||
return (
|
||||
<Box h="100%" onClick={clearSelection} width="100%">
|
||||
<Grid
|
||||
onClick={preventClick}
|
||||
maxH="100%"
|
||||
templateRows="auto minmax(0, 1fr)"
|
||||
min-w="100%"
|
||||
>
|
||||
<Flex direction="column" p="2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onChange={changeParticipantInput}
|
||||
value={participantInput}
|
||||
mb="2"
|
||||
placeholder="Participant Name"
|
||||
/>
|
||||
<Button
|
||||
onClick={doAction}
|
||||
colorScheme="blue"
|
||||
disabled={!action || anyLoading}
|
||||
>
|
||||
{!anyLoading ? (
|
||||
<>
|
||||
<Kbd color="blue.500" pt="1" mr="1">
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTurnDown}
|
||||
className="rotate-90 h-3"
|
||||
/>
|
||||
</Kbd>
|
||||
{action || "Create"}
|
||||
</>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{participants.response && (
|
||||
<UnorderedList
|
||||
mx="0"
|
||||
mb={{ base: 2, md: 4 }}
|
||||
maxH="100%"
|
||||
overflow="scroll"
|
||||
>
|
||||
{participants.response.map((participant: Participant) => (
|
||||
<ListItem
|
||||
onClick={selectParticipant(participant)}
|
||||
cursor="pointer"
|
||||
className={
|
||||
(participantInput.length > 0 &&
|
||||
selectedText &&
|
||||
participant.name.startsWith(participantInput)
|
||||
? "bg-blue-100 "
|
||||
: "") +
|
||||
(participant.id == selectedParticipant?.id
|
||||
? "bg-blue-200 border"
|
||||
: "")
|
||||
}
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
borderBottom="1px"
|
||||
borderColor="gray.300"
|
||||
p="2"
|
||||
mx="2"
|
||||
_last={{ borderBottom: "0" }}
|
||||
key={participant.name}
|
||||
>
|
||||
<Text mt="1">{participant.name}</Text>
|
||||
|
||||
<Box>
|
||||
{action == "Create to rename" &&
|
||||
!selectedParticipant &&
|
||||
!loading && (
|
||||
<Button
|
||||
onClick={mergeSpeaker(selectedText, participant)}
|
||||
colorScheme="blue"
|
||||
ml="2"
|
||||
size="sm"
|
||||
>
|
||||
{oneMatch?.id == participant.id &&
|
||||
action == "Create to rename" && (
|
||||
<Kbd
|
||||
letterSpacing="-1px"
|
||||
color="blue.500"
|
||||
mr="1"
|
||||
pt="3px"
|
||||
>
|
||||
Ctrl +
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTurnDown}
|
||||
className="rotate-90 h-2"
|
||||
/>
|
||||
</Kbd>
|
||||
)}
|
||||
Merge
|
||||
</Button>
|
||||
)}
|
||||
{selectedTextIsTimeSlice(selectedText) && !loading && (
|
||||
<Button
|
||||
onClick={assignTo(participant)}
|
||||
colorScheme="blue"
|
||||
ml="2"
|
||||
size="sm"
|
||||
>
|
||||
{oneMatch?.id == participant.id &&
|
||||
action == "Create and assign" && (
|
||||
<Kbd
|
||||
letterSpacing="-1px"
|
||||
color="blue.500"
|
||||
mr="1"
|
||||
pt="3px"
|
||||
>
|
||||
Ctrl +
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTurnDown}
|
||||
className="rotate-90 h-2"
|
||||
/>
|
||||
</Kbd>
|
||||
)}{" "}
|
||||
Assign
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={deleteParticipant(participant.id)}
|
||||
colorScheme="blue"
|
||||
ml="2"
|
||||
size="sm"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantList;
|
||||
@@ -0,0 +1,29 @@
|
||||
export default ({ playing }) => (
|
||||
<div className="flex justify-between w-14 h-6">
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-quiet" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-normal" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-quiet" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-loud" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-normal" : ""
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
163
www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx
Normal file
163
www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import useTopics from "../../useTopics";
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { GetTranscriptTopic } from "../../../../api";
|
||||
import {
|
||||
BoxProps,
|
||||
Box,
|
||||
Circle,
|
||||
Heading,
|
||||
Kbd,
|
||||
Skeleton,
|
||||
SkeletonCircle,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
|
||||
|
||||
type TopicHeader = {
|
||||
stateCurrentTopic: [
|
||||
GetTranscriptTopic | undefined,
|
||||
Dispatch<SetStateAction<GetTranscriptTopic | undefined>>,
|
||||
];
|
||||
transcriptId: string;
|
||||
topicWithWordsLoading: boolean;
|
||||
};
|
||||
|
||||
export default function TopicHeader({
|
||||
stateCurrentTopic,
|
||||
transcriptId,
|
||||
topicWithWordsLoading,
|
||||
...chakraProps
|
||||
}: TopicHeader & BoxProps) {
|
||||
const [currentTopic, setCurrentTopic] = stateCurrentTopic;
|
||||
const topics = useTopics(transcriptId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topics.loading && !currentTopic) {
|
||||
const sessionTopic = window.localStorage.getItem(
|
||||
transcriptId + "correct",
|
||||
);
|
||||
if (sessionTopic && topics?.topics?.find((t) => t.id == sessionTopic)) {
|
||||
setCurrentTopic(topics?.topics?.find((t) => t.id == sessionTopic));
|
||||
} else {
|
||||
setCurrentTopic(topics?.topics?.at(0));
|
||||
}
|
||||
}
|
||||
}, [topics.loading]);
|
||||
|
||||
const number = topics.topics?.findIndex(
|
||||
(topic) => topic.id == currentTopic?.id,
|
||||
);
|
||||
const canGoPrevious = typeof number == "number" && number > 0;
|
||||
const total = topics.topics?.length;
|
||||
const canGoNext = total && typeof number == "number" && number + 1 < total;
|
||||
|
||||
const onPrev = () => {
|
||||
if (topicWithWordsLoading) return;
|
||||
canGoPrevious && setCurrentTopic(topics.topics?.at(number - 1));
|
||||
};
|
||||
const onNext = () => {
|
||||
if (topicWithWordsLoading) return;
|
||||
canGoNext && setCurrentTopic(topics.topics?.at(number + 1));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
currentTopic?.id &&
|
||||
window.localStorage.setItem(transcriptId + "correct", currentTopic?.id);
|
||||
}, [currentTopic?.id]);
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key == "ArrowLeft") {
|
||||
onPrev();
|
||||
} else if (e.key == "ArrowRight") {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
document.addEventListener("keyup", keyHandler);
|
||||
return () => {
|
||||
document.removeEventListener("keyup", keyHandler);
|
||||
};
|
||||
});
|
||||
|
||||
const isLoaded = !!(
|
||||
topics.topics &&
|
||||
currentTopic &&
|
||||
typeof number == "number"
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
w="100%"
|
||||
justifyContent="space-between"
|
||||
{...chakraProps}
|
||||
>
|
||||
<SkeletonCircle
|
||||
isLoaded={isLoaded}
|
||||
h={isLoaded ? "auto" : "40px"}
|
||||
w={isLoaded ? "auto" : "40px"}
|
||||
mb="2"
|
||||
fadeDuration={1}
|
||||
>
|
||||
<Circle
|
||||
as="button"
|
||||
onClick={onPrev}
|
||||
disabled={!canGoPrevious}
|
||||
size="40px"
|
||||
border="1px"
|
||||
color={canGoPrevious ? "inherit" : "gray"}
|
||||
borderColor={canGoNext ? "body-text" : "gray"}
|
||||
>
|
||||
{canGoPrevious ? (
|
||||
<Kbd>
|
||||
<ChevronLeftIcon />
|
||||
</Kbd>
|
||||
) : (
|
||||
<ChevronLeftIcon />
|
||||
)}
|
||||
</Circle>
|
||||
</SkeletonCircle>
|
||||
<Skeleton
|
||||
isLoaded={isLoaded}
|
||||
h={isLoaded ? "auto" : "40px"}
|
||||
mb="2"
|
||||
fadeDuration={1}
|
||||
flexGrow={1}
|
||||
mx={6}
|
||||
>
|
||||
<Flex wrap="nowrap" justifyContent="center">
|
||||
<Heading size="lg" textAlign="center" noOfLines={1}>
|
||||
{currentTopic?.title}{" "}
|
||||
</Heading>
|
||||
<Heading size="lg" ml="3">
|
||||
{(number || 0) + 1}/{total}
|
||||
</Heading>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
<SkeletonCircle
|
||||
isLoaded={isLoaded}
|
||||
h={isLoaded ? "auto" : "40px"}
|
||||
w={isLoaded ? "auto" : "40px"}
|
||||
mb="2"
|
||||
fadeDuration={1}
|
||||
>
|
||||
<Circle
|
||||
as="button"
|
||||
onClick={onNext}
|
||||
disabled={!canGoNext}
|
||||
size="40px"
|
||||
border="1px"
|
||||
color={canGoNext ? "inherit" : "gray"}
|
||||
borderColor={canGoNext ? "body-text" : "gray"}
|
||||
>
|
||||
{canGoNext ? (
|
||||
<Kbd>
|
||||
<ChevronRightIcon />
|
||||
</Kbd>
|
||||
) : (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</Circle>
|
||||
</SkeletonCircle>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
245
www/app/(app)/transcripts/[transcriptId]/correct/topicPlayer.tsx
Normal file
245
www/app/(app)/transcripts/[transcriptId]/correct/topicPlayer.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useMp3 from "../../useMp3";
|
||||
import { formatTime } from "../../../../lib/time";
|
||||
import SoundWaveCss from "./soundWaveCss";
|
||||
import { TimeSlice } from "./types";
|
||||
import {
|
||||
BoxProps,
|
||||
Button,
|
||||
Wrap,
|
||||
Text,
|
||||
WrapItem,
|
||||
Kbd,
|
||||
Skeleton,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
type TopicPlayer = {
|
||||
transcriptId: string;
|
||||
selectedTime: TimeSlice | undefined;
|
||||
topicTime: TimeSlice | undefined;
|
||||
};
|
||||
|
||||
const TopicPlayer = ({
|
||||
transcriptId,
|
||||
selectedTime,
|
||||
topicTime,
|
||||
...chakraProps
|
||||
}: TopicPlayer & BoxProps) => {
|
||||
const mp3 = useMp3(transcriptId);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [endTopicCallback, setEndTopicCallback] = useState<() => void>();
|
||||
const [endSelectionCallback, setEndSelectionCallback] =
|
||||
useState<() => void>();
|
||||
const [showTime, setShowTime] = useState("");
|
||||
const playButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key == " ") {
|
||||
if (e.target.id != "playButton") {
|
||||
if (isPlaying) {
|
||||
mp3.media?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
mp3.media?.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
} else if (selectedTime && e.key == ",") {
|
||||
playSelection();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
document.addEventListener("keyup", keyHandler);
|
||||
return () => {
|
||||
document.removeEventListener("keyup", keyHandler);
|
||||
};
|
||||
});
|
||||
|
||||
const calcShowTime = () => {
|
||||
if (!topicTime) return;
|
||||
setShowTime(
|
||||
`${
|
||||
mp3.media?.currentTime
|
||||
? formatTime(mp3.media?.currentTime - topicTime.start)
|
||||
: "00:00"
|
||||
}/${formatTime(topicTime.end - topicTime.start)}`,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let i;
|
||||
if (isPlaying) {
|
||||
i = setInterval(calcShowTime, 1000);
|
||||
}
|
||||
return () => i && clearInterval(i);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
setEndTopicCallback(
|
||||
() =>
|
||||
function () {
|
||||
if (
|
||||
!topicTime ||
|
||||
!mp3.media ||
|
||||
!(mp3.media.currentTime >= topicTime.end)
|
||||
)
|
||||
return;
|
||||
mp3.media.pause();
|
||||
setIsPlaying(false);
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
calcShowTime();
|
||||
},
|
||||
);
|
||||
if (mp3.media) {
|
||||
playButton.current?.focus();
|
||||
mp3.media?.pause();
|
||||
// there's no callback on pause but apparently changing the time while palying doesn't work... so here is a timeout
|
||||
setTimeout(() => {
|
||||
if (mp3.media) {
|
||||
if (!topicTime) return;
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
setShowTime(`00:00/${formatTime(topicTime.end - topicTime.start)}`);
|
||||
}
|
||||
}, 10);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [!mp3.media, topicTime?.start, topicTime?.end]);
|
||||
|
||||
useEffect(() => {
|
||||
endTopicCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.addEventListener("timeupdate", endTopicCallback);
|
||||
|
||||
return () => {
|
||||
endTopicCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.removeEventListener("timeupdate", endTopicCallback);
|
||||
};
|
||||
}, [endTopicCallback]);
|
||||
|
||||
const playSelection = (e?) => {
|
||||
e?.preventDefault();
|
||||
e?.target?.blur();
|
||||
if (mp3.media && selectedTime?.start !== undefined) {
|
||||
mp3.media.currentTime = selectedTime.start;
|
||||
calcShowTime();
|
||||
setEndSelectionCallback(
|
||||
() =>
|
||||
function () {
|
||||
if (
|
||||
mp3.media &&
|
||||
selectedTime.end &&
|
||||
mp3.media.currentTime >= selectedTime.end
|
||||
) {
|
||||
mp3.media.pause();
|
||||
setIsPlaying(false);
|
||||
|
||||
setEndSelectionCallback(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
mp3.media.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
endSelectionCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.addEventListener("timeupdate", endSelectionCallback);
|
||||
return () => {
|
||||
endSelectionCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
|
||||
};
|
||||
}, [endSelectionCallback]);
|
||||
|
||||
const playTopic = (e) => {
|
||||
e?.preventDefault();
|
||||
e?.target?.blur();
|
||||
if (!topicTime) return;
|
||||
if (mp3.media) {
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
mp3.media.play();
|
||||
setIsPlaying(true);
|
||||
endSelectionCallback &&
|
||||
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
|
||||
}
|
||||
};
|
||||
|
||||
const playCurrent = (e) => {
|
||||
e.preventDefault();
|
||||
e?.target?.blur();
|
||||
|
||||
mp3.media?.play();
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const pause = (e) => {
|
||||
e.preventDefault();
|
||||
e?.target?.blur();
|
||||
|
||||
mp3.media?.pause();
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const isLoaded = !!(mp3.media && topicTime);
|
||||
return (
|
||||
<Skeleton
|
||||
isLoaded={isLoaded}
|
||||
h={isLoaded ? "auto" : "40px"}
|
||||
fadeDuration={1}
|
||||
w={isLoaded ? "auto" : "container.md"}
|
||||
margin="auto"
|
||||
{...chakraProps}
|
||||
>
|
||||
<Wrap spacing="4" justify="center" align="center">
|
||||
<WrapItem>
|
||||
<SoundWaveCss playing={isPlaying} />
|
||||
<Text fontSize="sm" pt="1" pl="2">
|
||||
{showTime}
|
||||
</Text>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<Button onClick={playTopic} colorScheme="blue">
|
||||
Play from start
|
||||
</Button>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
{!isPlaying ? (
|
||||
<Button
|
||||
onClick={playCurrent}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Play
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={pause}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Pause
|
||||
</Button>
|
||||
)}
|
||||
</WrapItem>
|
||||
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
|
||||
<Button
|
||||
disabled={!selectedTime}
|
||||
onClick={playSelection}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Kbd color="blue.600">,</Kbd> Play selection
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicPlayer;
|
||||
220
www/app/(app)/transcripts/[transcriptId]/correct/topicWords.tsx
Normal file
220
www/app/(app)/transcripts/[transcriptId]/correct/topicWords.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { UseParticipants } from "../../useParticipants";
|
||||
import { UseTopicWithWords } from "../../useTopicWithWords";
|
||||
import { TimeSlice, selectedTextIsTimeSlice } from "./types";
|
||||
import { BoxProps, Box, Container, Text, Spinner } from "@chakra-ui/react";
|
||||
|
||||
type TopicWordsProps = {
|
||||
stateSelectedText: [
|
||||
number | TimeSlice | undefined,
|
||||
Dispatch<SetStateAction<number | TimeSlice | undefined>>,
|
||||
];
|
||||
participants: UseParticipants;
|
||||
topicWithWords: UseTopicWithWords;
|
||||
};
|
||||
|
||||
const topicWords = ({
|
||||
stateSelectedText,
|
||||
participants,
|
||||
topicWithWords,
|
||||
...chakraProps
|
||||
}: TopicWordsProps & BoxProps) => {
|
||||
const [selectedText, setSelectedText] = stateSelectedText;
|
||||
|
||||
useEffect(() => {
|
||||
if (topicWithWords.loading && selectedTextIsTimeSlice(selectedText)) {
|
||||
setSelectedText(undefined);
|
||||
}
|
||||
}, [topicWithWords.loading]);
|
||||
|
||||
const getStartTimeFromFirstNode = (node, offset, reverse) => {
|
||||
// Check if the current node represents a word with a start time
|
||||
if (node.parentElement?.dataset["start"]) {
|
||||
// Check if the position is at the end of the word
|
||||
if (node.textContent?.length == offset) {
|
||||
// Try to get the start time of the next word
|
||||
const nextWordStartTime =
|
||||
node.parentElement.nextElementSibling?.dataset["start"];
|
||||
if (nextWordStartTime) {
|
||||
return nextWordStartTime;
|
||||
}
|
||||
|
||||
// If no next word, get start of the first word in the next paragraph
|
||||
const nextParaFirstWordStartTime =
|
||||
node.parentElement.parentElement.nextElementSibling?.childNodes[1]
|
||||
?.dataset["start"];
|
||||
if (nextParaFirstWordStartTime) {
|
||||
return nextParaFirstWordStartTime;
|
||||
}
|
||||
|
||||
// Return default values based on 'reverse' flag
|
||||
// If reverse is false, means the node is the last word of the topic transcript,
|
||||
// so reverse should be true, and we set a high value to make sure this is not picked as the start time.
|
||||
// Reverse being true never happens given how we use this function, but for consistency in case things change,
|
||||
// we set a low value.
|
||||
return reverse ? 0 : 9999999999999;
|
||||
} else {
|
||||
// Position is within the word, return start of this word
|
||||
return node.parentElement.dataset["start"];
|
||||
}
|
||||
} else {
|
||||
// Selection is on a name, return start of the next word
|
||||
return node.parentElement.nextElementSibling?.dataset["start"];
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e) => {
|
||||
let selection = window.getSelection();
|
||||
if (
|
||||
selection &&
|
||||
selection.anchorNode &&
|
||||
selection.focusNode &&
|
||||
selection.anchorNode == selection.focusNode &&
|
||||
selection.anchorOffset == selection.focusOffset
|
||||
) {
|
||||
setSelectedText(undefined);
|
||||
selection.empty();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection &&
|
||||
selection.anchorNode &&
|
||||
selection.focusNode &&
|
||||
(selection.anchorNode !== selection.focusNode ||
|
||||
selection.anchorOffset !== selection.focusOffset)
|
||||
) {
|
||||
const anchorNode = selection.anchorNode;
|
||||
const anchorIsWord =
|
||||
!!selection.anchorNode.parentElement?.dataset["start"];
|
||||
const focusNode = selection.focusNode;
|
||||
const focusIsWord = !!selection.focusNode.parentElement?.dataset["end"];
|
||||
|
||||
// If selected a speaker :
|
||||
if (
|
||||
!anchorIsWord &&
|
||||
!focusIsWord &&
|
||||
anchorNode.parentElement == focusNode.parentElement
|
||||
) {
|
||||
setSelectedText(
|
||||
focusNode.parentElement?.dataset["speaker"]
|
||||
? parseInt(focusNode.parentElement?.dataset["speaker"])
|
||||
: undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorStart = getStartTimeFromFirstNode(
|
||||
anchorNode,
|
||||
selection.anchorOffset,
|
||||
false,
|
||||
);
|
||||
// if selection end on a word, we get the end time from the span that contains it
|
||||
const focusEnd =
|
||||
selection.focusOffset !== 0
|
||||
? selection.focusNode.parentElement?.dataset["end"] ||
|
||||
// otherwise it was a name and we get the end of the last word of the previous paragraph
|
||||
(
|
||||
selection.focusNode.parentElement?.parentElement
|
||||
?.previousElementSibling?.lastElementChild as any
|
||||
)?.dataset["end"]
|
||||
: (selection.focusNode.parentElement?.previousElementSibling as any)
|
||||
?.dataset["end"] || 0;
|
||||
|
||||
const reverse = parseFloat(anchorStart) >= parseFloat(focusEnd);
|
||||
|
||||
if (!reverse) {
|
||||
anchorStart &&
|
||||
focusEnd &&
|
||||
setSelectedText({
|
||||
start: parseFloat(anchorStart),
|
||||
end: parseFloat(focusEnd),
|
||||
});
|
||||
} else {
|
||||
const anchorEnd =
|
||||
anchorNode.parentElement?.dataset["end"] ||
|
||||
(
|
||||
selection.anchorNode.parentElement?.parentElement
|
||||
?.previousElementSibling?.lastElementChild as any
|
||||
)?.dataset["end"];
|
||||
|
||||
const focusStart = getStartTimeFromFirstNode(
|
||||
focusNode,
|
||||
selection.focusOffset,
|
||||
true,
|
||||
);
|
||||
|
||||
setSelectedText({
|
||||
start: parseFloat(focusStart),
|
||||
end: parseFloat(anchorEnd),
|
||||
});
|
||||
}
|
||||
}
|
||||
selection && selection.empty();
|
||||
};
|
||||
|
||||
const getSpeakerName = (speakerNumber: number) => {
|
||||
if (!participants.response) return;
|
||||
return (
|
||||
participants.response.find(
|
||||
(participant) => participant.speaker == speakerNumber,
|
||||
)?.name || `Speaker ${speakerNumber}`
|
||||
);
|
||||
};
|
||||
if (
|
||||
!topicWithWords.loading &&
|
||||
topicWithWords.response &&
|
||||
participants.response
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
onMouseUp={onMouseUp}
|
||||
max-h="100%"
|
||||
width="100%"
|
||||
overflow="scroll"
|
||||
maxW={{ lg: "container.md" }}
|
||||
{...chakraProps}
|
||||
>
|
||||
{topicWithWords.response.words_per_speaker?.map(
|
||||
(speakerWithWords, index) => (
|
||||
<Text key={index} className="mb-2 last:mb-0">
|
||||
<Box
|
||||
as="span"
|
||||
data-speaker={speakerWithWords.speaker}
|
||||
pt="1"
|
||||
fontWeight="semibold"
|
||||
bgColor={
|
||||
selectedText == speakerWithWords.speaker ? "yellow.200" : ""
|
||||
}
|
||||
>
|
||||
{getSpeakerName(speakerWithWords.speaker)} :
|
||||
</Box>
|
||||
{speakerWithWords.words.map((word, index) => (
|
||||
<Box
|
||||
as="span"
|
||||
data-start={word.start}
|
||||
data-end={word.end}
|
||||
key={index}
|
||||
pt="1"
|
||||
bgColor={
|
||||
selectedTextIsTimeSlice(selectedText) &&
|
||||
selectedText.start <= word.start &&
|
||||
selectedText.end >= word.end
|
||||
? "yellow.200"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{word.text}
|
||||
</Box>
|
||||
))}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
if (topicWithWords.loading || participants.loading)
|
||||
return <Spinner size="xl" margin="auto" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export default topicWords;
|
||||
20
www/app/(app)/transcripts/[transcriptId]/correct/types.ts
Normal file
20
www/app/(app)/transcripts/[transcriptId]/correct/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type TimeSlice = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type SelectedText = number | TimeSlice | undefined;
|
||||
|
||||
export function selectedTextIsSpeaker(
|
||||
selectedText: SelectedText,
|
||||
): selectedText is number {
|
||||
return typeof selectedText == "number";
|
||||
}
|
||||
export function selectedTextIsTimeSlice(
|
||||
selectedText: SelectedText,
|
||||
): selectedText is TimeSlice {
|
||||
return (
|
||||
typeof (selectedText as any)?.start == "number" &&
|
||||
typeof (selectedText as any)?.end == "number"
|
||||
);
|
||||
}
|
||||
154
www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
Normal file
154
www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import "../../../styles/markdown.css";
|
||||
import {
|
||||
GetTranscript,
|
||||
GetTranscriptTopic,
|
||||
UpdateTranscript,
|
||||
} from "../../../api";
|
||||
import useApi from "../../../lib/useApi";
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Button,
|
||||
Textarea,
|
||||
Spacer,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaPen } from "react-icons/fa";
|
||||
import { useError } from "../../../(errors)/errorContext";
|
||||
import ShareAndPrivacy from "../shareAndPrivacy";
|
||||
|
||||
type FinalSummaryProps = {
|
||||
transcriptResponse: GetTranscript;
|
||||
topicsResponse: GetTranscriptTopic[];
|
||||
onUpdate?: (newSummary) => void;
|
||||
};
|
||||
|
||||
export default function FinalSummary(props: FinalSummaryProps) {
|
||||
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [preEditSummary, setPreEditSummary] = useState("");
|
||||
const [editedSummary, setEditedSummary] = useState("");
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const { setError } = useError();
|
||||
|
||||
useEffect(() => {
|
||||
setEditedSummary(props.transcriptResponse?.long_summary || "");
|
||||
}, [props.transcriptResponse?.long_summary]);
|
||||
|
||||
if (!props.topicsResponse || !props.transcriptResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateSummary = async (newSummary: string, transcriptId: string) => {
|
||||
try {
|
||||
const requestBody: UpdateTranscript = {
|
||||
long_summary: newSummary,
|
||||
};
|
||||
const updatedTranscript = await api?.v1TranscriptUpdate({
|
||||
transcriptId,
|
||||
requestBody,
|
||||
});
|
||||
if (props.onUpdate) {
|
||||
props.onUpdate(newSummary);
|
||||
}
|
||||
console.log("Updated long summary:", updatedTranscript);
|
||||
} catch (err) {
|
||||
console.error("Failed to update long summary:", err);
|
||||
setError(err, "Failed to update long summary.");
|
||||
}
|
||||
};
|
||||
|
||||
const onEditClick = () => {
|
||||
setPreEditSummary(editedSummary);
|
||||
setIsEditMode(true);
|
||||
};
|
||||
|
||||
const onDiscardClick = () => {
|
||||
setEditedSummary(preEditSummary);
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const onSaveClick = () => {
|
||||
updateSummary(editedSummary, props.transcriptResponse.id);
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const handleTextAreaKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onDiscardClick();
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
onSaveClick();
|
||||
e.preventDefault(); // prevent the default action of adding a new line
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
maxH={"100%"}
|
||||
h={"100%"}
|
||||
overflowY={isEditMode ? "hidden" : "auto"}
|
||||
pb={4}
|
||||
>
|
||||
<Flex dir="row" justify="start" align="center" wrap={"wrap-reverse"}>
|
||||
<Heading size={{ base: "md" }}>Summary</Heading>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Spacer />
|
||||
<Button
|
||||
onClick={onDiscardClick}
|
||||
colorScheme="gray"
|
||||
variant={"text"}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button onClick={onSaveClick} colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<FaPen />}
|
||||
aria-label="Edit Summary"
|
||||
onClick={onEditClick}
|
||||
/>
|
||||
<Spacer />
|
||||
<ShareAndPrivacy
|
||||
finalSummaryRef={finalSummaryRef}
|
||||
transcriptResponse={props.transcriptResponse}
|
||||
topicsResponse={props.topicsResponse}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{isEditMode ? (
|
||||
<Textarea
|
||||
value={editedSummary}
|
||||
onChange={(e) => setEditedSummary(e.target.value)}
|
||||
className="markdown"
|
||||
onKeyDown={(e) => handleTextAreaKeyDown(e)}
|
||||
h={"100%"}
|
||||
resize={"none"}
|
||||
mt={2}
|
||||
/>
|
||||
) : (
|
||||
<div ref={finalSummaryRef} className="markdown">
|
||||
<Markdown>{editedSummary}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
147
www/app/(app)/transcripts/[transcriptId]/page.tsx
Normal file
147
www/app/(app)/transcripts/[transcriptId]/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import Modal from "../modal";
|
||||
import useTranscript from "../useTranscript";
|
||||
import useTopics from "../useTopics";
|
||||
import useWaveform from "../useWaveform";
|
||||
import useMp3 from "../useMp3";
|
||||
import { TopicList } from "../topicList";
|
||||
import { Topic } from "../webSocketTypes";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "../../../styles/button.css";
|
||||
import FinalSummary from "./finalSummary";
|
||||
import TranscriptTitle from "../transcriptTitle";
|
||||
import Player from "../player";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
transcriptId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
const transcriptId = details.params.transcriptId;
|
||||
const router = useRouter();
|
||||
const statusToRedirect = ["idle", "recording", "processing"];
|
||||
|
||||
const transcript = useTranscript(transcriptId);
|
||||
const transcriptStatus = transcript.response?.status;
|
||||
const waiting = statusToRedirect.includes(transcriptStatus || "");
|
||||
|
||||
const topics = useTopics(transcriptId);
|
||||
const waveform = useWaveform(transcriptId, waiting);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
const mp3 = useMp3(transcriptId, waiting);
|
||||
|
||||
useEffect(() => {
|
||||
if (waiting) {
|
||||
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
|
||||
// Shallow redirection does not work on NextJS 13
|
||||
// https://github.com/vercel/next.js/discussions/48110
|
||||
// https://github.com/vercel/next.js/discussions/49540
|
||||
router.replace(newUrl);
|
||||
// history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [waiting]);
|
||||
|
||||
if (transcript.error || topics?.error) {
|
||||
return (
|
||||
<Modal
|
||||
title="Transcription Not Found"
|
||||
text="A trascription with this ID does not exist."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (transcript?.loading || topics?.loading) {
|
||||
return <Modal title="Loading" text={"Loading transcript..."} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
templateColumns="1fr"
|
||||
templateRows="auto minmax(0, 1fr)"
|
||||
gap={4}
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
{waveform.waveform && mp3.media && topics.topics ? (
|
||||
<Player
|
||||
topics={topics?.topics}
|
||||
useActiveTopic={useActiveTopic}
|
||||
waveform={waveform.waveform}
|
||||
media={mp3.media}
|
||||
mediaDuration={transcript.response.duration}
|
||||
/>
|
||||
) : waveform.error ? (
|
||||
<div>"error loading this recording"</div>
|
||||
) : (
|
||||
<Skeleton h={14} />
|
||||
)}
|
||||
<Grid
|
||||
templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
|
||||
templateRows={{
|
||||
base: "auto minmax(0, 1fr) minmax(0, 1fr)",
|
||||
md: "auto minmax(0, 1fr)",
|
||||
}}
|
||||
gap={2}
|
||||
padding={4}
|
||||
paddingBottom={0}
|
||||
background="gray.bg"
|
||||
border={"2px solid"}
|
||||
borderColor={"gray.bg"}
|
||||
borderRadius={8}
|
||||
>
|
||||
<GridItem
|
||||
display="flex"
|
||||
flexDir="row"
|
||||
alignItems={"center"}
|
||||
colSpan={{ base: 1, md: 2 }}
|
||||
>
|
||||
<TranscriptTitle
|
||||
title={transcript.response.title || "Unnamed Transcript"}
|
||||
transcriptId={transcriptId}
|
||||
onUpdate={(newTitle) => {
|
||||
transcript.reload();
|
||||
}}
|
||||
/>
|
||||
</GridItem>
|
||||
<TopicList
|
||||
topics={topics.topics || []}
|
||||
useActiveTopic={useActiveTopic}
|
||||
autoscroll={false}
|
||||
transcriptId={transcriptId}
|
||||
status={transcript.response?.status}
|
||||
currentTranscriptText=""
|
||||
/>
|
||||
{transcript.response && topics.topics ? (
|
||||
<>
|
||||
<FinalSummary
|
||||
transcriptResponse={transcript.response}
|
||||
topicsResponse={topics.topics}
|
||||
onUpdate={(newSummary) => {
|
||||
transcript.reload();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Flex justify={"center"} alignItems={"center"} h={"100%"}>
|
||||
<div className="flex flex-col h-full justify-center content-center">
|
||||
{transcript.response.status == "processing" ? (
|
||||
<Text>Loading Transcript</Text>
|
||||
) : (
|
||||
<Text>
|
||||
There was an error generating the final summary, please come
|
||||
back later
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
www/app/(app)/transcripts/[transcriptId]/record/page.tsx
Normal file
130
www/app/(app)/transcripts/[transcriptId]/record/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Recorder from "../../recorder";
|
||||
import { TopicList } from "../../topicList";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useWebSockets } from "../../useWebSockets";
|
||||
import "../../../../styles/button.css";
|
||||
import { Topic } from "../../webSocketTypes";
|
||||
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Player from "../../player";
|
||||
import useMp3 from "../../useMp3";
|
||||
import WaveformLoading from "../../waveformLoading";
|
||||
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
|
||||
import LiveTrancription from "../../liveTranscription";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
transcriptId: string;
|
||||
};
|
||||
};
|
||||
|
||||
const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
const transcript = useTranscript(details.params.transcriptId);
|
||||
const [transcriptStarted, setTranscriptStarted] = useState(false);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
|
||||
const webSockets = useWebSockets(details.params.transcriptId);
|
||||
|
||||
let mp3 = useMp3(details.params.transcriptId, true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState(
|
||||
webSockets.status.value || transcript.response?.status || "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
|
||||
setTranscriptStarted(true);
|
||||
}, [webSockets.transcriptTextLive]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO HANDLE ERROR STATUS BETTER
|
||||
const newStatus =
|
||||
webSockets.status.value || transcript.response?.status || "idle";
|
||||
setStatus(newStatus);
|
||||
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
|
||||
console.log(newStatus, "redirecting");
|
||||
|
||||
const newUrl = "/transcripts/" + details.params.transcriptId;
|
||||
router.replace(newUrl);
|
||||
}
|
||||
}, [webSockets.status.value, transcript.response?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
|
||||
}, [webSockets.waveform, webSockets.duration]);
|
||||
|
||||
useEffect(() => {
|
||||
lockWakeState();
|
||||
return () => {
|
||||
releaseWakeState();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateColumns="1fr"
|
||||
templateRows="auto minmax(0, 1fr) "
|
||||
gap={4}
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
{status == "processing" ? (
|
||||
<WaveformLoading />
|
||||
) : (
|
||||
// todo: only start recording animation when you get "recorded" status
|
||||
<Recorder transcriptId={details.params.transcriptId} status={status} />
|
||||
)}
|
||||
<VStack
|
||||
align={"left"}
|
||||
w="full"
|
||||
h="full"
|
||||
mb={4}
|
||||
background="gray.bg"
|
||||
border={"2px solid"}
|
||||
borderColor={"gray.bg"}
|
||||
borderRadius={8}
|
||||
p="4"
|
||||
>
|
||||
<Heading size={"lg"}>
|
||||
{status === "processing" ? "Processing meeting" : "Record meeting"}
|
||||
</Heading>
|
||||
|
||||
<Flex direction={{ base: "column-reverse", md: "row" }} h={"full"}>
|
||||
<Box w={{ md: "50%" }} h={{ base: "80%", md: "full" }}>
|
||||
<TopicList
|
||||
topics={webSockets.topics}
|
||||
useActiveTopic={useActiveTopic}
|
||||
autoscroll={true}
|
||||
transcriptId={details.params.transcriptId}
|
||||
status={status}
|
||||
currentTranscriptText={webSockets.accumulatedText}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ md: "50%" }} h={{ base: "20%", md: "full" }}>
|
||||
{!transcriptStarted ? (
|
||||
<Box textAlign={"center"} textColor="gray">
|
||||
<Text>
|
||||
Live transcript will appear here shortly after you'll start
|
||||
recording.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
status === "recording" && (
|
||||
<LiveTrancription
|
||||
text={webSockets.transcriptTextLive}
|
||||
translateText={webSockets.translateText}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranscriptRecord;
|
||||
159
www/app/(app)/transcripts/[transcriptId]/shareModal.tsx
Normal file
159
www/app/(app)/transcripts/[transcriptId]/shareModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import SelectSearch from "react-select-search";
|
||||
import { getZulipMessage, sendZulipMessage } from "../../../lib/zulip";
|
||||
import { GetTranscript, GetTranscriptTopic } from "../../../api";
|
||||
import "react-select-search/style.css";
|
||||
import { DomainContext } from "../../../domainContext";
|
||||
|
||||
type ShareModal = {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
transcript: GetTranscript | null;
|
||||
topics: GetTranscriptTopic[] | null;
|
||||
};
|
||||
|
||||
interface Stream {
|
||||
id: number;
|
||||
name: string;
|
||||
topics: string[];
|
||||
}
|
||||
|
||||
interface SelectSearchOption {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ShareModal = (props: ShareModal) => {
|
||||
const [stream, setStream] = useState<string | undefined>(undefined);
|
||||
const [topic, setTopic] = useState<string | undefined>(undefined);
|
||||
const [includeTopics, setIncludeTopics] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [streams, setStreams] = useState<Stream[]>([]);
|
||||
const { zulip_streams } = useContext(DomainContext);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(zulip_streams + "/streams.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
data = data.sort((a: Stream, b: Stream) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
setStreams(data);
|
||||
setIsLoading(false);
|
||||
// data now contains the JavaScript object decoded from JSON
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("There was a problem with your fetch operation:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSendToZulip = () => {
|
||||
if (!props.transcript) return;
|
||||
|
||||
const msg = getZulipMessage(props.transcript, props.topics, includeTopics);
|
||||
|
||||
if (stream && topic) sendZulipMessage(stream, topic, msg);
|
||||
};
|
||||
|
||||
if (props.show && isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
let streamOptions: SelectSearchOption[] = [];
|
||||
if (streams) {
|
||||
streams.forEach((stream) => {
|
||||
const value = stream.name;
|
||||
streamOptions.push({ name: value, value: value });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute">
|
||||
{props.show && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3 text-center">
|
||||
<h3 className="font-bold text-xl">Send to Zulip</h3>
|
||||
|
||||
{/* Checkbox for 'Include Topics' */}
|
||||
<div className="mt-4 text-left ml-5">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
checked={includeTopics}
|
||||
onChange={(e) => setIncludeTopics(e.target.checked)}
|
||||
/>
|
||||
<span className="ml-2">Include topics</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-4">
|
||||
<span className="mr-2">#</span>
|
||||
<SelectSearch
|
||||
search={true}
|
||||
options={streamOptions}
|
||||
value={stream}
|
||||
onChange={(val) => {
|
||||
setTopic(undefined);
|
||||
setStream(val.toString());
|
||||
}}
|
||||
placeholder="Pick a stream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stream && (
|
||||
<>
|
||||
<div className="flex items-center mt-4">
|
||||
<span className="mr-2 invisible">#</span>
|
||||
<SelectSearch
|
||||
search={true}
|
||||
options={
|
||||
streams
|
||||
.find((s) => s.name == stream)
|
||||
?.topics.sort((a: string, b: string) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
.map((t) => ({ name: t, value: t })) || []
|
||||
}
|
||||
value={topic}
|
||||
onChange={(val) => setTopic(val.toString())}
|
||||
placeholder="Pick a topic"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded py-2 px-4 mr-3 ${
|
||||
!stream || !topic ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
disabled={!stream || !topic}
|
||||
onClick={() => {
|
||||
handleSendToZulip();
|
||||
props.setShow(false);
|
||||
}}
|
||||
>
|
||||
Send to Zulip
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-700 focus-visible:bg-red-700 text-white rounded py-2 px-4 mt-4"
|
||||
onClick={() => props.setShow(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareModal;
|
||||
113
www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
Normal file
113
www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useWebSockets } from "../../useWebSockets";
|
||||
import "../../../../styles/button.css";
|
||||
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useMp3 from "../../useMp3";
|
||||
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
|
||||
import FileUploadButton from "../../fileUploadButton";
|
||||
|
||||
type TranscriptUpload = {
|
||||
params: {
|
||||
transcriptId: string;
|
||||
};
|
||||
};
|
||||
|
||||
const TranscriptUpload = (details: TranscriptUpload) => {
|
||||
const transcript = useTranscript(details.params.transcriptId);
|
||||
const [transcriptStarted, setTranscriptStarted] = useState(false);
|
||||
|
||||
const webSockets = useWebSockets(details.params.transcriptId);
|
||||
|
||||
let mp3 = useMp3(details.params.transcriptId, true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState(
|
||||
webSockets.status.value || transcript.response?.status || "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0)
|
||||
setTranscriptStarted(true);
|
||||
}, [webSockets.transcriptTextLive]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO HANDLE ERROR STATUS BETTER
|
||||
const newStatus =
|
||||
webSockets.status.value || transcript.response?.status || "idle";
|
||||
setStatus(newStatus);
|
||||
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
|
||||
console.log(newStatus, "redirecting");
|
||||
|
||||
const newUrl = "/transcripts/" + details.params.transcriptId;
|
||||
router.replace(newUrl);
|
||||
}
|
||||
}, [webSockets.status.value, transcript.response?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
|
||||
}, [webSockets.waveform, webSockets.duration]);
|
||||
|
||||
useEffect(() => {
|
||||
lockWakeState();
|
||||
return () => {
|
||||
releaseWakeState();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack
|
||||
align={"left"}
|
||||
w="full"
|
||||
h="full"
|
||||
mb={4}
|
||||
background="gray.bg"
|
||||
border={"2px solid"}
|
||||
borderColor={"gray.bg"}
|
||||
borderRadius={8}
|
||||
p="4"
|
||||
>
|
||||
<Heading size={"lg"}>Upload meeting</Heading>
|
||||
<Center h={"full"} w="full">
|
||||
<VStack spacing={10}>
|
||||
{status && status == "idle" && (
|
||||
<>
|
||||
<Text>
|
||||
Please select the file, supported formats: .mp3, m4a, .wav,
|
||||
.mp4, .mov or .webm
|
||||
</Text>
|
||||
<FileUploadButton transcriptId={details.params.transcriptId} />
|
||||
</>
|
||||
)}
|
||||
{status && status == "uploaded" && (
|
||||
<Text>File is uploaded, processing...</Text>
|
||||
)}
|
||||
{(status == "recording" || status == "processing") && (
|
||||
<>
|
||||
<Heading size={"lg"}>Processing your recording...</Heading>
|
||||
<Text>
|
||||
You can safely return to the library while your file is being
|
||||
processed.
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => {
|
||||
router.push("/browse");
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
</VStack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranscriptUpload;
|
||||
Reference in New Issue
Block a user