Merge pull request #336 from Monadical-SAS/sara/feat-speaker-reassign

Sara/feat speaker reassign
This commit is contained in:
Sara
2024-01-16 16:35:11 +01:00
committed by GitHub
26 changed files with 3133 additions and 207 deletions

View File

@@ -157,14 +157,17 @@ class Transcript(BaseModel):
self.topics.append(topic)
def upsert_participant(self, participant: TranscriptParticipant):
index = next(
(i for i, p in enumerate(self.participants) if p.id == participant.id),
None,
)
if index is not None:
self.participants[index] = participant
if self.participants:
index = next(
(i for i, p in enumerate(self.participants) if p.id == participant.id),
None,
)
if index is not None:
self.participants[index] = participant
else:
self.participants.append(participant)
else:
self.participants.append(participant)
self.participants = [participant]
return participant
def delete_participant(self, participant_id: str):

View File

@@ -233,7 +233,9 @@ async def transcript_update(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id)
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
values = info.dict(exclude_unset=True)

View File

@@ -41,6 +41,9 @@ async def transcript_get_participants(
transcript_id, user_id=user_id
)
if transcript.participants is None:
return []
return [
Participant.model_validate(participant)
for participant in transcript.participants
@@ -59,7 +62,7 @@ async def transcript_add_participant(
)
# ensure the speaker is unique
if participant.speaker is not None:
if participant.speaker is not None and transcript.participants is not None:
for p in transcript.participants:
if p.speaker == participant.speaker:
raise HTTPException(

View File

@@ -14,13 +14,7 @@ export default function TranscriptBrowser() {
const { loading, response } = useTranscriptList(page);
return (
<div>
{/*
<div className="flex flex-row gap-2">
<input className="text-sm p-2 w-80 ring-1 ring-slate-900/10 shadow-sm rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 caret-blue-500" placeholder="Search" />
</div>
*/}
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 h-full max-h-full">
<div className="flex flex-row gap-2 items-center">
<Title className="mb-5 mt-5 flex-1">Past transcripts</Title>
<Pagination
@@ -48,8 +42,8 @@ export default function TranscriptBrowser() {
&nbsp;to get started.
</div>
)}
<div /** center and max 900px wide */ className="mx-auto max-w-[900px]">
<div className="grid grid-cols-1 gap-2 lg:gap-4 h-full">
<div /** center and max 900px wide */ className="overflow-y-scroll">
<div className="grid grid-cols-1 gap-2 lg:gap-4 h-full mx-auto max-w-[900px]">
{response?.items.map((item: GetTranscript) => (
<div
key={item.id}
@@ -59,7 +53,7 @@ export default function TranscriptBrowser() {
<div className="flex flex-row gap-2 items-start">
<Link
href={`/transcripts/${item.id}`}
className="text-1xl font-semibold flex-1 pl-0 hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
className="text-1xl flex-1 pl-0 hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
>
{item.title || item.name}
</Link>

View File

@@ -14,6 +14,7 @@ import { getConfig } from "../lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { cookies } from "next/dist/client/components/headers";
import { SESSION_COOKIE_NAME } from "../lib/fief";
import { Providers } from "../providers";
const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
@@ -79,83 +80,89 @@ export default async function RootLayout({ children, params }: LayoutProps) {
return (
<html lang="en">
<body className={poppins.className + " h-screen relative"}>
<body
className={
poppins.className + "h-[100svh] w-[100svw] overflow-hidden relative"
}
>
<FiefWrapper hasAuthCookie={hasAuthCookie}>
<DomainContextProvider config={config}>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider>
<ErrorMessage />
<div
id="container"
className="items-center h-[100svh] w-[100svw] p-2 md:p-4 grid grid-rows-layout gap-2 md:gap-4"
>
<header className="flex justify-between items-center w-full">
{/* Logo on the left */}
<Link
href="/"
className="flex outline-blue-300 md:outline-none focus-visible:underline underline-offset-2 decoration-[.5px] decoration-gray-500"
>
<Image
src="/reach.png"
width={16}
height={16}
className="h-10 w-auto"
alt="Reflector"
/>
<div className="hidden flex-col ml-2 md:block">
<h1 className="text-[38px] font-bold tracking-wide leading-tight">
Reflector
</h1>
<p className="text-gray-500 text-xs tracking-tighter">
Capture the signal, not the noise
</p>
</div>
</Link>
<div>
{/* Text link on the right */}
<Providers>
<div
id="container"
className="items-center h-[100svh] w-[100svw] p-2 md:p-4 grid grid-rows-layout-topbar gap-2 md:gap-4"
>
<header className="flex justify-between items-center w-full">
{/* Logo on the left */}
<Link
href="/transcripts/new"
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
href="/"
className="flex outline-blue-300 md:outline-none focus-visible:underline underline-offset-2 decoration-[.5px] decoration-gray-500"
>
Create
<Image
src="/reach.png"
width={16}
height={16}
className="h-10 w-auto"
alt="Reflector"
/>
<div className="hidden flex-col ml-2 md:block">
<h1 className="text-[38px] font-bold tracking-wide leading-tight">
Reflector
</h1>
<p className="text-gray-500 text-xs tracking-tighter">
Capture the signal, not the noise
</p>
</div>
</Link>
{browse ? (
<>
&nbsp;·&nbsp;
<Link
href="/browse"
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
prefetch={false}
>
Browse
</Link>
</>
) : (
<></>
)}
&nbsp;·&nbsp;
<About buttonText="About" />
{privacy ? (
<>
&nbsp;·&nbsp;
<Privacy buttonText="Privacy" />
</>
) : (
<></>
)}
{requireLogin ? (
<>
&nbsp;·&nbsp;
<UserInfo />
</>
) : (
<></>
)}
</div>
</header>
<div>
{/* Text link on the right */}
<Link
href="/transcripts/new"
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
>
Create
</Link>
{browse ? (
<>
&nbsp;·&nbsp;
<Link
href="/browse"
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
prefetch={false}
>
Browse
</Link>
</>
) : (
<></>
)}
&nbsp;·&nbsp;
<About buttonText="About" />
{privacy ? (
<>
&nbsp;·&nbsp;
<Privacy buttonText="Privacy" />
</>
) : (
<></>
)}
{requireLogin ? (
<>
&nbsp;·&nbsp;
<UserInfo />
</>
) : (
<></>
)}
</div>
</header>
{children}
</div>
{children}
</div>
</Providers>
</ErrorProvider>
</ErrorBoundary>
</DomainContextProvider>

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, { 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,463 @@
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, {
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, {
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,
participantTo.id,
{ 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, participant.id, {
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, {
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,
{
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, {
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

@@ -38,7 +38,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => {
const statusToRedirect = ["idle", "recording", "processing"];
if (statusToRedirect.includes(transcript.response?.status)) {
if (statusToRedirect.includes(transcript.response?.status || "")) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
@@ -55,100 +55,99 @@ export default function TranscriptDetails(details: TranscriptDetails) {
.replace(/ +/g, " ")
.trim() || "";
if (transcript && transcript.response) {
if (transcript.error || topics?.error) {
return (
<Modal
title="Transcription Not Found"
text="A trascription with this ID does not exist."
/>
);
}
if (!transcriptId || transcript?.loading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />;
}
if (transcript.error || topics?.error) {
return (
<>
{featureEnabled("sendToZulip") && (
<ShareModal
transcript={transcript.response}
topics={topics ? topics.topics : null}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
)}
<div className="flex flex-col">
{transcript?.response?.title && (
<TranscriptTitle
title={transcript.response.title}
transcriptId={transcript.response.id}
/>
)}
{waveform.waveform && mp3.media ? (
<Player
topics={topics?.topics || []}
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
/>
) : waveform.error ? (
<div>"error loading this recording"</div>
) : (
<WaveformLoading />
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full">
<TopicList
topics={topics.topics || []}
useActiveTopic={useActiveTopic}
autoscroll={false}
/>
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full">
{transcript.response.long_summary ? (
<FinalSummary
fullTranscript={fullTranscript}
summary={transcript.response.long_summary}
transcriptId={transcript.response.id}
openZulipModal={() => setShowModal(true)}
/>
) : (
<div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? (
<p>Loading Transcript</p>
) : (
<p>
There was an error generating the final summary, please
come back later
</p>
)}
</div>
)}
</section>
<section className="flex items-center">
<div className="mr-4 hidden md:block h-auto">
<QRCode
value={`${location.origin}/transcripts/${details.params.transcriptId}`}
level="L"
size={98}
/>
</div>
<div className="flex-grow max-w-full">
<ShareLink
transcriptId={transcript?.response?.id}
userId={transcript?.response?.user_id}
shareMode={toShareMode(transcript?.response?.share_mode)}
/>
</div>
</section>
</div>
</div>
</>
<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 (
<div className="grid grid-rows-layout-topbar h-full max-h-full gap-2 lg:gap-4">
{featureEnabled("sendToZulip") && (
<ShareModal
transcript={transcript.response}
topics={topics ? topics.topics : null}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
)}
<div className="flex flex-col">
{transcript?.response?.title && (
<TranscriptTitle
title={transcript.response.title}
transcriptId={transcript.response.id}
/>
)}
{waveform.waveform && mp3.media ? (
<Player
topics={topics?.topics || []}
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
/>
) : waveform.error ? (
<div>"error loading this recording"</div>
) : (
<WaveformLoading />
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full">
<TopicList
topics={topics.topics || []}
useActiveTopic={useActiveTopic}
autoscroll={false}
transcriptId={transcriptId}
/>
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full">
{transcript.response.long_summary ? (
<FinalSummary
fullTranscript={fullTranscript}
summary={transcript.response.long_summary}
transcriptId={transcript.response.id}
openZulipModal={() => setShowModal(true)}
/>
) : (
<div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? (
<p>Loading Transcript</p>
) : (
<p>
There was an error generating the final summary, please come
back later
</p>
)}
</div>
)}
</section>
<section className="flex items-center">
<div className="mr-4 hidden md:block h-auto">
<QRCode
value={`${location.origin}/transcripts/${details.params.transcriptId}`}
level="L"
size={98}
/>
</div>
<div className="flex-grow max-w-full">
<ShareLink
transcriptId={transcript.response.id}
userId={transcript.response.user_id}
shareMode={toShareMode(transcript.response.share_mode)}
/>
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -62,8 +62,9 @@ const TranscriptRecord = (details: TranscriptDetails) => {
//TODO if has no topic and is error, get back to new
if (
statusToRedirect.includes(transcript.response?.status) ||
statusToRedirect.includes(webSockets.status.value)
transcript.response?.status &&
(statusToRedirect.includes(transcript.response?.status) ||
statusToRedirect.includes(webSockets.status.value))
) {
const newUrl = "/transcripts/" + details.params.transcriptId;
// Shallow redirection does not work on NextJS 13
@@ -86,7 +87,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
}, []);
return (
<>
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full h-full">
{webSockets.waveform && webSockets.duration && mp3?.media ? (
<Player
topics={webSockets.topics || []}
@@ -119,6 +120,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
topics={webSockets.topics}
useActiveTopic={useActiveTopic}
autoscroll={true}
transcriptId={details.params.transcriptId}
/>
<section
@@ -165,7 +167,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
</div>
{disconnected && <DisconnectedIndicator />}
</>
</div>
);
};

View File

@@ -0,0 +1,32 @@
[
{
"id": "27c07e49-d7a3-4b86-905c-f1a047366f91",
"title": "Issue one",
"summary": "The team discusses the first issue in the list",
"timestamp": 0.0,
"transcript": "",
"duration": 33,
"segments": [
{
"text": "Let's start with issue one, Alice you've been working on that, can you give an update ?",
"start": 0.0,
"speaker": 0
},
{
"text": "Yes, I've run into an issue with the task system but Bob helped me out and I have a POC ready, should I present it now ?",
"start": 0.38,
"speaker": 1
},
{
"text": "Yeah, I had to modify the task system because it didn't account for incoming blobs",
"start": 4.5,
"speaker": 2
},
{
"text": "Cool, yeah lets see it",
"start": 5.96,
"speaker": 0
}
]
}
]

View File

@@ -51,8 +51,7 @@ const TranscriptCreate = () => {
useAudioDevice();
return (
<>
<div className="hidden lg:block"></div>
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full overflow-y-scroll">
<div className="lg:grid lg:grid-cols-2 lg:grid-rows-1 lg:gap-4 lg:h-full h-auto flex flex-col">
<section className="flex flex-col w-full lg:h-full items-center justify-evenly p-4 md:px-6 md:py-8">
<div className="flex flex-col max-w-xl items-center justify-center">
@@ -143,7 +142,7 @@ const TranscriptCreate = () => {
)}
</section>
</div>
</>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants";
type TopicListProps = {
topics: Topic[];
@@ -16,15 +17,18 @@ type TopicListProps = {
React.Dispatch<React.SetStateAction<Topic | null>>,
];
autoscroll: boolean;
transcriptId: string;
};
export function TopicList({
topics,
useActiveTopic,
autoscroll,
transcriptId,
}: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
const participants = useParticipants(transcriptId);
useEffect(() => {
if (autoscroll && autoscrollEnabled) scrollToBottom();
@@ -61,6 +65,15 @@ export function TopicList({
}
}, [activeTopic, autoscroll]);
const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return;
return (
participants.response.find(
(participant) => participant.speaker == speakerNumber,
)?.name || `Speaker ${speakerNumber}`
);
};
return (
<section className="relative w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl p-1 sm:p-2 md:px-4 flex flex-col justify-center align-center">
{topics.length > 0 ? (
@@ -125,7 +138,7 @@ export function TopicList({
}}
>
{" "}
(Speaker {segment.speaker}):
{getSpeakerName(segment.speaker)}:
</span>{" "}
<span>{segment.text}</span>
</p>

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Participant } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type ErrorParticipants = {
error: Error;
loading: false;
response: null;
};
type LoadingParticipants = {
response: Participant[] | null;
loading: true;
error: null;
};
type SuccessParticipants = {
response: Participant[];
loading: boolean;
error: null;
};
export type UseParticipants = (
| ErrorParticipants
| LoadingParticipants
| SuccessParticipants
) & { refetch: () => void };
const useParticipants = (transcriptId: string): UseParticipants => {
const [response, setResponse] = useState<Participant[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [count, setCount] = useState(0);
const refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
useEffect(() => {
if (!transcriptId || !api) return;
setLoading(true);
api
.v1TranscriptGetParticipants(transcriptId)
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Participants Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the participants");
} else {
setError(error);
}
setErrorState(error);
setResponse(null);
setLoading(false);
});
}, [transcriptId, !api, count]);
return { response, loading, error, refetch } as UseParticipants;
};
export default useParticipants;

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { GetTranscriptTopicWithWordsPerSpeaker } from "../../api";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
type ErrorTopicWithWords = {
error: Error;
loading: false;
response: null;
};
type LoadingTopicWithWords = {
response: GetTranscriptTopicWithWordsPerSpeaker | null;
loading: true;
error: false;
};
type SuccessTopicWithWords = {
response: GetTranscriptTopicWithWordsPerSpeaker;
loading: false;
error: null;
};
export type UseTopicWithWords = { refetch: () => void } & (
| ErrorTopicWithWords
| LoadingTopicWithWords
| SuccessTopicWithWords
);
const useTopicWithWords = (
topicId: string | undefined,
transcriptId: string,
): UseTopicWithWords => {
const [response, setResponse] =
useState<GetTranscriptTopicWithWordsPerSpeaker | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [count, setCount] = useState(0);
const refetch = () => {
if (!loading) {
setCount(count + 1);
setLoading(true);
setErrorState(null);
}
};
useEffect(() => {
if (!transcriptId || !topicId || !api) return;
setLoading(true);
api
.v1TranscriptGetTopicsWithWordsPerSpeaker(transcriptId, topicId)
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Topics with words Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the topics with words");
} else {
setError(error);
}
setErrorState(error);
});
}, [transcriptId, !api, topicId, count]);
return { response, loading, error, refetch } as UseTopicWithWords;
};
export default useTopicWithWords;

View File

@@ -1,22 +1,23 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import { Topic } from "./webSocketTypes";
import useApi from "../../lib/useApi";
import { shouldShowError } from "../../lib/errorUtils";
import { GetTranscriptTopic } from "../../api";
type TranscriptTopics = {
topics: Topic[] | null;
topics: GetTranscriptTopic[] | null;
loading: boolean;
error: Error | null;
};
const useTopics = (id: string): TranscriptTopics => {
const [topics, setTopics] = useState<Topic[] | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
useEffect(() => {
if (!id || !api) return;

View File

@@ -7,11 +7,11 @@ import useApi from "../../lib/useApi";
type ErrorTranscript = {
error: Error;
loading: false;
response: any;
response: null;
};
type LoadingTranscript = {
response: any;
response: null;
loading: true;
error: false;
};

7
www/app/providers.tsx Normal file
View File

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

View File

@@ -8,6 +8,10 @@ type LanguageOption = {
};
const supportedLanguages: LanguageOption[] = [
{
value: "",
name: "No translation",
},
{
value: "af",
name: "Afrikaans",

View File

@@ -11,6 +11,13 @@
"openapi": "openapi --input http://127.0.0.1:1250/openapi.json --name OpenApi --output app/api && yarn format"
},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/menu": "^2.2.1",
"@chakra-ui/next-js": "^2.2.0",
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/react-types": "^2.0.6",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fief/fief": "^0.13.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
@@ -19,9 +26,11 @@
"@vercel/edge-config": "^0.4.1",
"autoprefixer": "10.4.14",
"axios": "^1.6.2",
"chakra-react-select": "^4.7.6",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"fontawesome": "^5.6.3",
"framer-motion": "^10.16.16",
"jest-worker": "^29.6.2",
"next": "^14.0.4",
"postcss": "8.4.25",

View File

@@ -9,12 +9,50 @@ module.exports = {
theme: {
extend: {
gridTemplateRows: {
layout: "auto auto minmax(0, 1fr)",
"layout-topbar": "auto minmax(0,1fr)",
"mobile-inner": "minmax(0, 2fr) minmax(0, 1fr)",
"layout-one": "minmax(0, 1fr) auto",
},
animation: {
"spin-slow": "spin 3s linear infinite",
"wave-quiet": "wave-quiet 1.2s ease-in-out infinite",
"wave-normal": "wave-normal 1.2s ease-in-out infinite",
"wave-loud": "wave-loud 1.2s ease-in-out infinite",
},
keyframes: {
"wave-quiet": {
"25%": {
transform: "scaleY(.6)",
},
"50%": {
transform: "scaleY(.4)",
},
"75%": {
transform: "scaleY(.4)",
},
},
"wave-normal": {
"25%": {
transform: "scaleY(1)",
},
"50%": {
transform: "scaleY(.4)",
},
"75%": {
transform: "scaleY(.6)",
},
},
"wave-loud": {
"25%": {
transform: "scaleY(1)",
},
"50%": {
transform: "scaleY(.4)",
},
"75%": {
transform: "scaleY(1.2)",
},
},
},
colors: {
bluegrey: "RGB(90, 122, 158)",

File diff suppressed because it is too large Load Diff