Remove domain segment

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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