mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Merge pull request #336 from Monadical-SAS/sara/feat-speaker-reassign
Sara/feat speaker reassign
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
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>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
·
|
||||
<Link
|
||||
href="/browse"
|
||||
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
|
||||
prefetch={false}
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
·
|
||||
<About buttonText="About" />
|
||||
{privacy ? (
|
||||
<>
|
||||
·
|
||||
<Privacy buttonText="Privacy" />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{requireLogin ? (
|
||||
<>
|
||||
·
|
||||
<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 ? (
|
||||
<>
|
||||
·
|
||||
<Link
|
||||
href="/browse"
|
||||
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
|
||||
prefetch={false}
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
·
|
||||
<About buttonText="About" />
|
||||
{privacy ? (
|
||||
<>
|
||||
·
|
||||
<Privacy buttonText="Privacy" />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{requireLogin ? (
|
||||
<>
|
||||
·
|
||||
<UserInfo />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
</ErrorProvider>
|
||||
</ErrorBoundary>
|
||||
</DomainContextProvider>
|
||||
|
||||
123
www/app/[domain]/transcripts/[transcriptId]/correct/page.tsx
Normal file
123
www/app/[domain]/transcripts/[transcriptId]/correct/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import TopicHeader from "./topicHeader";
|
||||
import TopicWords from "./topicWords";
|
||||
import TopicPlayer from "./topicPlayer";
|
||||
import useParticipants from "../../useParticipants";
|
||||
import useTopicWithWords from "../../useTopicWithWords";
|
||||
import ParticipantList from "./participantList";
|
||||
import { GetTranscriptTopic } from "../../../../api";
|
||||
import { SelectedText, selectedTextIsTimeSlice } from "./types";
|
||||
import useApi from "../../../../lib/useApi";
|
||||
import useTranscript from "../../useTranscript";
|
||||
import { useError } from "../../../../(errors)/errorContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Box, Grid } from "@chakra-ui/react";
|
||||
|
||||
export type TranscriptCorrect = {
|
||||
params: {
|
||||
transcriptId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function TranscriptCorrect({
|
||||
params: { transcriptId },
|
||||
}: TranscriptCorrect) {
|
||||
const api = useApi();
|
||||
const transcript = useTranscript(transcriptId);
|
||||
const stateCurrentTopic = useState<GetTranscriptTopic>();
|
||||
const [currentTopic, _sct] = stateCurrentTopic;
|
||||
const stateSelectedText = useState<SelectedText>();
|
||||
const [selectedText, _sst] = stateSelectedText;
|
||||
const topicWithWords = useTopicWithWords(currentTopic?.id, transcriptId);
|
||||
const participants = useParticipants(transcriptId);
|
||||
const { setError } = useError();
|
||||
const router = useRouter();
|
||||
|
||||
const markAsDone = () => {
|
||||
if (transcript.response && !transcript.response.reviewed) {
|
||||
api
|
||||
?.v1TranscriptUpdate(transcriptId, { reviewed: true })
|
||||
.then(() => {
|
||||
router.push(`/transcripts/${transcriptId}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e, "Error marking as done");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateRows="auto minmax(0, 1fr)"
|
||||
h="100%"
|
||||
maxW={{ lg: "container.lg" }}
|
||||
mx="auto"
|
||||
minW={{ base: "100%", lg: "container.lg" }}
|
||||
>
|
||||
<Box display="flex" flexDir="column" minW="100%" mb={{ base: 4, lg: 10 }}>
|
||||
<TopicHeader
|
||||
minW="100%"
|
||||
stateCurrentTopic={stateCurrentTopic}
|
||||
transcriptId={transcriptId}
|
||||
topicWithWordsLoading={topicWithWords.loading}
|
||||
/>
|
||||
|
||||
<TopicPlayer
|
||||
transcriptId={transcriptId}
|
||||
selectedTime={
|
||||
selectedTextIsTimeSlice(selectedText) ? selectedText : undefined
|
||||
}
|
||||
topicTime={
|
||||
currentTopic
|
||||
? {
|
||||
start: currentTopic?.timestamp,
|
||||
end: currentTopic?.timestamp + (currentTopic?.duration || 0),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "minmax(0, 1fr)",
|
||||
md: "4fr 3fr",
|
||||
lg: "2fr 1fr",
|
||||
}}
|
||||
templateRows={{
|
||||
base: "repeat(2, minmax(0, 1fr)) auto",
|
||||
md: "minmax(0, 1fr)",
|
||||
}}
|
||||
gap={{ base: "2", md: "4", lg: "4" }}
|
||||
h="100%"
|
||||
maxH="100%"
|
||||
w="100%"
|
||||
>
|
||||
<TopicWords
|
||||
stateSelectedText={stateSelectedText}
|
||||
participants={participants}
|
||||
topicWithWords={topicWithWords}
|
||||
mb={{ md: "-3rem" }}
|
||||
/>
|
||||
<ParticipantList
|
||||
{...{
|
||||
transcriptId,
|
||||
participants,
|
||||
topicWithWords,
|
||||
stateSelectedText,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{transcript.response && !transcript.response?.reviewed && (
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="p-2 px-4 rounded bg-green-400"
|
||||
onClick={markAsDone}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 +
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTurnDown}
|
||||
className="rotate-90 h-2"
|
||||
/>
|
||||
</Kbd>
|
||||
)}
|
||||
Merge
|
||||
</Button>
|
||||
)}
|
||||
{selectedTextIsTimeSlice(selectedText) && !loading && (
|
||||
<Button
|
||||
onClick={assignTo(participant)}
|
||||
colorScheme="blue"
|
||||
ml="2"
|
||||
size="sm"
|
||||
>
|
||||
{oneMatch?.id == participant.id &&
|
||||
action == "Create and assign" && (
|
||||
<Kbd
|
||||
letterSpacing="-1px"
|
||||
color="blue.500"
|
||||
mr="1"
|
||||
pt="3px"
|
||||
>
|
||||
Ctrl +
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTurnDown}
|
||||
className="rotate-90 h-2"
|
||||
/>
|
||||
</Kbd>
|
||||
)}{" "}
|
||||
Assign
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={deleteParticipant(participant.id)}
|
||||
colorScheme="blue"
|
||||
ml="2"
|
||||
size="sm"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantList;
|
||||
@@ -0,0 +1,29 @@
|
||||
export default ({ playing }) => (
|
||||
<div className="flex justify-between w-14 h-6">
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-quiet" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-normal" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-quiet" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-loud" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`bg-blue-400 rounded w-2 ${
|
||||
playing ? "animate-wave-normal" : ""
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useMp3 from "../../useMp3";
|
||||
import { formatTime } from "../../../../lib/time";
|
||||
import SoundWaveCss from "./soundWaveCss";
|
||||
import { TimeSlice } from "./types";
|
||||
import {
|
||||
BoxProps,
|
||||
Button,
|
||||
Wrap,
|
||||
Text,
|
||||
WrapItem,
|
||||
Kbd,
|
||||
Skeleton,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
type TopicPlayer = {
|
||||
transcriptId: string;
|
||||
selectedTime: TimeSlice | undefined;
|
||||
topicTime: TimeSlice | undefined;
|
||||
};
|
||||
|
||||
const TopicPlayer = ({
|
||||
transcriptId,
|
||||
selectedTime,
|
||||
topicTime,
|
||||
...chakraProps
|
||||
}: TopicPlayer & BoxProps) => {
|
||||
const mp3 = useMp3(transcriptId);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [endTopicCallback, setEndTopicCallback] = useState<() => void>();
|
||||
const [endSelectionCallback, setEndSelectionCallback] =
|
||||
useState<() => void>();
|
||||
const [showTime, setShowTime] = useState("");
|
||||
const playButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key == " ") {
|
||||
if (e.target.id != "playButton") {
|
||||
if (isPlaying) {
|
||||
mp3.media?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
mp3.media?.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
} else if (selectedTime && e.key == ",") {
|
||||
playSelection();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
document.addEventListener("keyup", keyHandler);
|
||||
return () => {
|
||||
document.removeEventListener("keyup", keyHandler);
|
||||
};
|
||||
});
|
||||
|
||||
const calcShowTime = () => {
|
||||
if (!topicTime) return;
|
||||
setShowTime(
|
||||
`${
|
||||
mp3.media?.currentTime
|
||||
? formatTime(mp3.media?.currentTime - topicTime.start)
|
||||
: "00:00"
|
||||
}/${formatTime(topicTime.end - topicTime.start)}`,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let i;
|
||||
if (isPlaying) {
|
||||
i = setInterval(calcShowTime, 1000);
|
||||
}
|
||||
return () => i && clearInterval(i);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
setEndTopicCallback(
|
||||
() =>
|
||||
function () {
|
||||
if (
|
||||
!topicTime ||
|
||||
!mp3.media ||
|
||||
!(mp3.media.currentTime >= topicTime.end)
|
||||
)
|
||||
return;
|
||||
mp3.media.pause();
|
||||
setIsPlaying(false);
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
calcShowTime();
|
||||
},
|
||||
);
|
||||
if (mp3.media) {
|
||||
playButton.current?.focus();
|
||||
mp3.media?.pause();
|
||||
// there's no callback on pause but apparently changing the time while palying doesn't work... so here is a timeout
|
||||
setTimeout(() => {
|
||||
if (mp3.media) {
|
||||
if (!topicTime) return;
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
setShowTime(`00:00/${formatTime(topicTime.end - topicTime.start)}`);
|
||||
}
|
||||
}, 10);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [!mp3.media, topicTime?.start, topicTime?.end]);
|
||||
|
||||
useEffect(() => {
|
||||
endTopicCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.addEventListener("timeupdate", endTopicCallback);
|
||||
|
||||
return () => {
|
||||
endTopicCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.removeEventListener("timeupdate", endTopicCallback);
|
||||
};
|
||||
}, [endTopicCallback]);
|
||||
|
||||
const playSelection = (e?) => {
|
||||
e?.preventDefault();
|
||||
e?.target?.blur();
|
||||
if (mp3.media && selectedTime?.start !== undefined) {
|
||||
mp3.media.currentTime = selectedTime.start;
|
||||
calcShowTime();
|
||||
setEndSelectionCallback(
|
||||
() =>
|
||||
function () {
|
||||
if (
|
||||
mp3.media &&
|
||||
selectedTime.end &&
|
||||
mp3.media.currentTime >= selectedTime.end
|
||||
) {
|
||||
mp3.media.pause();
|
||||
setIsPlaying(false);
|
||||
|
||||
setEndSelectionCallback(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
mp3.media.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
endSelectionCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.addEventListener("timeupdate", endSelectionCallback);
|
||||
return () => {
|
||||
endSelectionCallback &&
|
||||
mp3.media &&
|
||||
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
|
||||
};
|
||||
}, [endSelectionCallback]);
|
||||
|
||||
const playTopic = (e) => {
|
||||
e?.preventDefault();
|
||||
e?.target?.blur();
|
||||
if (!topicTime) return;
|
||||
if (mp3.media) {
|
||||
mp3.media.currentTime = topicTime.start;
|
||||
mp3.media.play();
|
||||
setIsPlaying(true);
|
||||
endSelectionCallback &&
|
||||
mp3.media.removeEventListener("timeupdate", endSelectionCallback);
|
||||
}
|
||||
};
|
||||
|
||||
const playCurrent = (e) => {
|
||||
e.preventDefault();
|
||||
e?.target?.blur();
|
||||
|
||||
mp3.media?.play();
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const pause = (e) => {
|
||||
e.preventDefault();
|
||||
e?.target?.blur();
|
||||
|
||||
mp3.media?.pause();
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const isLoaded = !!(mp3.media && topicTime);
|
||||
return (
|
||||
<Skeleton
|
||||
isLoaded={isLoaded}
|
||||
h={isLoaded ? "auto" : "40px"}
|
||||
fadeDuration={1}
|
||||
w={isLoaded ? "auto" : "container.md"}
|
||||
margin="auto"
|
||||
{...chakraProps}
|
||||
>
|
||||
<Wrap spacing="4" justify="center" align="center">
|
||||
<WrapItem>
|
||||
<SoundWaveCss playing={isPlaying} />
|
||||
<Text fontSize="sm" pt="1" pl="2">
|
||||
{showTime}
|
||||
</Text>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<Button onClick={playTopic} colorScheme="blue">
|
||||
Play from start
|
||||
</Button>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
{!isPlaying ? (
|
||||
<Button
|
||||
onClick={playCurrent}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Play
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={pause}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Pause
|
||||
</Button>
|
||||
)}
|
||||
</WrapItem>
|
||||
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
|
||||
<Button
|
||||
disabled={!selectedTime}
|
||||
onClick={playSelection}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Kbd color="blue.600">,</Kbd> Play selection
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicPlayer;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { UseParticipants } from "../../useParticipants";
|
||||
import { UseTopicWithWords } from "../../useTopicWithWords";
|
||||
import { TimeSlice, selectedTextIsTimeSlice } from "./types";
|
||||
import { BoxProps, Box, Container, Text, Spinner } from "@chakra-ui/react";
|
||||
|
||||
type TopicWordsProps = {
|
||||
stateSelectedText: [
|
||||
number | TimeSlice | undefined,
|
||||
Dispatch<SetStateAction<number | TimeSlice | undefined>>,
|
||||
];
|
||||
participants: UseParticipants;
|
||||
topicWithWords: UseTopicWithWords;
|
||||
};
|
||||
|
||||
const topicWords = ({
|
||||
stateSelectedText,
|
||||
participants,
|
||||
topicWithWords,
|
||||
...chakraProps
|
||||
}: TopicWordsProps & BoxProps) => {
|
||||
const [selectedText, setSelectedText] = stateSelectedText;
|
||||
|
||||
useEffect(() => {
|
||||
if (topicWithWords.loading && selectedTextIsTimeSlice(selectedText)) {
|
||||
setSelectedText(undefined);
|
||||
}
|
||||
}, [topicWithWords.loading]);
|
||||
|
||||
const getStartTimeFromFirstNode = (node, offset, reverse) => {
|
||||
// Check if the current node represents a word with a start time
|
||||
if (node.parentElement?.dataset["start"]) {
|
||||
// Check if the position is at the end of the word
|
||||
if (node.textContent?.length == offset) {
|
||||
// Try to get the start time of the next word
|
||||
const nextWordStartTime =
|
||||
node.parentElement.nextElementSibling?.dataset["start"];
|
||||
if (nextWordStartTime) {
|
||||
return nextWordStartTime;
|
||||
}
|
||||
|
||||
// If no next word, get start of the first word in the next paragraph
|
||||
const nextParaFirstWordStartTime =
|
||||
node.parentElement.parentElement.nextElementSibling?.childNodes[1]
|
||||
?.dataset["start"];
|
||||
if (nextParaFirstWordStartTime) {
|
||||
return nextParaFirstWordStartTime;
|
||||
}
|
||||
|
||||
// Return default values based on 'reverse' flag
|
||||
// If reverse is false, means the node is the last word of the topic transcript,
|
||||
// so reverse should be true, and we set a high value to make sure this is not picked as the start time.
|
||||
// Reverse being true never happens given how we use this function, but for consistency in case things change,
|
||||
// we set a low value.
|
||||
return reverse ? 0 : 9999999999999;
|
||||
} else {
|
||||
// Position is within the word, return start of this word
|
||||
return node.parentElement.dataset["start"];
|
||||
}
|
||||
} else {
|
||||
// Selection is on a name, return start of the next word
|
||||
return node.parentElement.nextElementSibling?.dataset["start"];
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e) => {
|
||||
let selection = window.getSelection();
|
||||
if (
|
||||
selection &&
|
||||
selection.anchorNode &&
|
||||
selection.focusNode &&
|
||||
selection.anchorNode == selection.focusNode &&
|
||||
selection.anchorOffset == selection.focusOffset
|
||||
) {
|
||||
setSelectedText(undefined);
|
||||
selection.empty();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection &&
|
||||
selection.anchorNode &&
|
||||
selection.focusNode &&
|
||||
(selection.anchorNode !== selection.focusNode ||
|
||||
selection.anchorOffset !== selection.focusOffset)
|
||||
) {
|
||||
const anchorNode = selection.anchorNode;
|
||||
const anchorIsWord =
|
||||
!!selection.anchorNode.parentElement?.dataset["start"];
|
||||
const focusNode = selection.focusNode;
|
||||
const focusIsWord = !!selection.focusNode.parentElement?.dataset["end"];
|
||||
|
||||
// If selected a speaker :
|
||||
if (
|
||||
!anchorIsWord &&
|
||||
!focusIsWord &&
|
||||
anchorNode.parentElement == focusNode.parentElement
|
||||
) {
|
||||
setSelectedText(
|
||||
focusNode.parentElement?.dataset["speaker"]
|
||||
? parseInt(focusNode.parentElement?.dataset["speaker"])
|
||||
: undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorStart = getStartTimeFromFirstNode(
|
||||
anchorNode,
|
||||
selection.anchorOffset,
|
||||
false,
|
||||
);
|
||||
// if selection end on a word, we get the end time from the span that contains it
|
||||
const focusEnd =
|
||||
selection.focusOffset !== 0
|
||||
? selection.focusNode.parentElement?.dataset["end"] ||
|
||||
// otherwise it was a name and we get the end of the last word of the previous paragraph
|
||||
(
|
||||
selection.focusNode.parentElement?.parentElement
|
||||
?.previousElementSibling?.lastElementChild as any
|
||||
)?.dataset["end"]
|
||||
: (selection.focusNode.parentElement?.previousElementSibling as any)
|
||||
?.dataset["end"] || 0;
|
||||
|
||||
const reverse = parseFloat(anchorStart) >= parseFloat(focusEnd);
|
||||
|
||||
if (!reverse) {
|
||||
anchorStart &&
|
||||
focusEnd &&
|
||||
setSelectedText({
|
||||
start: parseFloat(anchorStart),
|
||||
end: parseFloat(focusEnd),
|
||||
});
|
||||
} else {
|
||||
const anchorEnd =
|
||||
anchorNode.parentElement?.dataset["end"] ||
|
||||
(
|
||||
selection.anchorNode.parentElement?.parentElement
|
||||
?.previousElementSibling?.lastElementChild as any
|
||||
)?.dataset["end"];
|
||||
|
||||
const focusStart = getStartTimeFromFirstNode(
|
||||
focusNode,
|
||||
selection.focusOffset,
|
||||
true,
|
||||
);
|
||||
|
||||
setSelectedText({
|
||||
start: parseFloat(focusStart),
|
||||
end: parseFloat(anchorEnd),
|
||||
});
|
||||
}
|
||||
}
|
||||
selection && selection.empty();
|
||||
};
|
||||
|
||||
const getSpeakerName = (speakerNumber: number) => {
|
||||
if (!participants.response) return;
|
||||
return (
|
||||
participants.response.find(
|
||||
(participant) => participant.speaker == speakerNumber,
|
||||
)?.name || `Speaker ${speakerNumber}`
|
||||
);
|
||||
};
|
||||
if (
|
||||
!topicWithWords.loading &&
|
||||
topicWithWords.response &&
|
||||
participants.response
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
onMouseUp={onMouseUp}
|
||||
max-h="100%"
|
||||
width="100%"
|
||||
overflow="scroll"
|
||||
maxW={{ lg: "container.md" }}
|
||||
{...chakraProps}
|
||||
>
|
||||
{topicWithWords.response.words_per_speaker?.map(
|
||||
(speakerWithWords, index) => (
|
||||
<Text key={index} className="mb-2 last:mb-0">
|
||||
<Box
|
||||
as="span"
|
||||
data-speaker={speakerWithWords.speaker}
|
||||
pt="1"
|
||||
fontWeight="semibold"
|
||||
bgColor={
|
||||
selectedText == speakerWithWords.speaker ? "yellow.200" : ""
|
||||
}
|
||||
>
|
||||
{getSpeakerName(speakerWithWords.speaker)} :
|
||||
</Box>
|
||||
{speakerWithWords.words.map((word, index) => (
|
||||
<Box
|
||||
as="span"
|
||||
data-start={word.start}
|
||||
data-end={word.end}
|
||||
key={index}
|
||||
pt="1"
|
||||
bgColor={
|
||||
selectedTextIsTimeSlice(selectedText) &&
|
||||
selectedText.start <= word.start &&
|
||||
selectedText.end >= word.end
|
||||
? "yellow.200"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{word.text}
|
||||
</Box>
|
||||
))}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
if (topicWithWords.loading || participants.loading)
|
||||
return <Spinner size="xl" margin="auto" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export default topicWords;
|
||||
20
www/app/[domain]/transcripts/[transcriptId]/correct/types.ts
Normal file
20
www/app/[domain]/transcripts/[transcriptId]/correct/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type TimeSlice = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type SelectedText = number | TimeSlice | undefined;
|
||||
|
||||
export function selectedTextIsSpeaker(
|
||||
selectedText: SelectedText,
|
||||
): selectedText is number {
|
||||
return typeof selectedText == "number";
|
||||
}
|
||||
export function selectedTextIsTimeSlice(
|
||||
selectedText: SelectedText,
|
||||
): selectedText is TimeSlice {
|
||||
return (
|
||||
typeof (selectedText as any)?.start == "number" &&
|
||||
typeof (selectedText as any)?.end == "number"
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
32
www/app/[domain]/transcripts/mockTopics.json
Normal file
32
www/app/[domain]/transcripts/mockTopics.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
www/app/[domain]/transcripts/useParticipants.ts
Normal file
74
www/app/[domain]/transcripts/useParticipants.ts
Normal 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;
|
||||
79
www/app/[domain]/transcripts/useTopicWithWords.ts
Normal file
79
www/app/[domain]/transcripts/useTopicWithWords.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
7
www/app/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <ChakraProvider>{children}</ChakraProvider>;
|
||||
}
|
||||
@@ -8,6 +8,10 @@ type LanguageOption = {
|
||||
};
|
||||
|
||||
const supportedLanguages: LanguageOption[] = [
|
||||
{
|
||||
value: "",
|
||||
name: "No translation",
|
||||
},
|
||||
{
|
||||
value: "af",
|
||||
name: "Afrikaans",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
1421
www/yarn.lock
1421
www/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user