mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39:06 +00:00
Merge pull request #336 from Monadical-SAS/sara/feat-speaker-reassign
Sara/feat speaker reassign
This commit is contained in:
@@ -157,6 +157,7 @@ class Transcript(BaseModel):
|
|||||||
self.topics.append(topic)
|
self.topics.append(topic)
|
||||||
|
|
||||||
def upsert_participant(self, participant: TranscriptParticipant):
|
def upsert_participant(self, participant: TranscriptParticipant):
|
||||||
|
if self.participants:
|
||||||
index = next(
|
index = next(
|
||||||
(i for i, p in enumerate(self.participants) if p.id == participant.id),
|
(i for i, p in enumerate(self.participants) if p.id == participant.id),
|
||||||
None,
|
None,
|
||||||
@@ -165,6 +166,8 @@ class Transcript(BaseModel):
|
|||||||
self.participants[index] = participant
|
self.participants[index] = participant
|
||||||
else:
|
else:
|
||||||
self.participants.append(participant)
|
self.participants.append(participant)
|
||||||
|
else:
|
||||||
|
self.participants = [participant]
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
def delete_participant(self, participant_id: str):
|
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: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
):
|
):
|
||||||
user_id = user["sub"] if user else None
|
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:
|
if not transcript:
|
||||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||||
values = info.dict(exclude_unset=True)
|
values = info.dict(exclude_unset=True)
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ async def transcript_get_participants(
|
|||||||
transcript_id, user_id=user_id
|
transcript_id, user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if transcript.participants is None:
|
||||||
|
return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Participant.model_validate(participant)
|
Participant.model_validate(participant)
|
||||||
for participant in transcript.participants
|
for participant in transcript.participants
|
||||||
@@ -59,7 +62,7 @@ async def transcript_add_participant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ensure the speaker is unique
|
# 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:
|
for p in transcript.participants:
|
||||||
if p.speaker == participant.speaker:
|
if p.speaker == participant.speaker:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -14,13 +14,7 @@ export default function TranscriptBrowser() {
|
|||||||
const { loading, response } = useTranscriptList(page);
|
const { loading, response } = useTranscriptList(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
|
||||||
<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="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<Title className="mb-5 mt-5 flex-1">Past transcripts</Title>
|
<Title className="mb-5 mt-5 flex-1">Past transcripts</Title>
|
||||||
<Pagination
|
<Pagination
|
||||||
@@ -48,8 +42,8 @@ export default function TranscriptBrowser() {
|
|||||||
to get started.
|
to get started.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div /** center and max 900px wide */ className="mx-auto max-w-[900px]">
|
<div /** center and max 900px wide */ className="overflow-y-scroll">
|
||||||
<div className="grid grid-cols-1 gap-2 lg:gap-4 h-full">
|
<div className="grid grid-cols-1 gap-2 lg:gap-4 h-full mx-auto max-w-[900px]">
|
||||||
{response?.items.map((item: GetTranscript) => (
|
{response?.items.map((item: GetTranscript) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -59,7 +53,7 @@ export default function TranscriptBrowser() {
|
|||||||
<div className="flex flex-row gap-2 items-start">
|
<div className="flex flex-row gap-2 items-start">
|
||||||
<Link
|
<Link
|
||||||
href={`/transcripts/${item.id}`}
|
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}
|
{item.title || item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getConfig } from "../lib/edgeConfig";
|
|||||||
import { ErrorBoundary } from "@sentry/nextjs";
|
import { ErrorBoundary } from "@sentry/nextjs";
|
||||||
import { cookies } from "next/dist/client/components/headers";
|
import { cookies } from "next/dist/client/components/headers";
|
||||||
import { SESSION_COOKIE_NAME } from "../lib/fief";
|
import { SESSION_COOKIE_NAME } from "../lib/fief";
|
||||||
|
import { Providers } from "../providers";
|
||||||
|
|
||||||
const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
|
const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
|
||||||
|
|
||||||
@@ -79,15 +80,20 @@ export default async function RootLayout({ children, params }: LayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={poppins.className + " h-screen relative"}>
|
<body
|
||||||
|
className={
|
||||||
|
poppins.className + "h-[100svh] w-[100svw] overflow-hidden relative"
|
||||||
|
}
|
||||||
|
>
|
||||||
<FiefWrapper hasAuthCookie={hasAuthCookie}>
|
<FiefWrapper hasAuthCookie={hasAuthCookie}>
|
||||||
<DomainContextProvider config={config}>
|
<DomainContextProvider config={config}>
|
||||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||||
<ErrorProvider>
|
<ErrorProvider>
|
||||||
<ErrorMessage />
|
<ErrorMessage />
|
||||||
|
<Providers>
|
||||||
<div
|
<div
|
||||||
id="container"
|
id="container"
|
||||||
className="items-center h-[100svh] w-[100svw] p-2 md:p-4 grid grid-rows-layout gap-2 md:gap-4"
|
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">
|
<header className="flex justify-between items-center w-full">
|
||||||
{/* Logo on the left */}
|
{/* Logo on the left */}
|
||||||
@@ -156,6 +162,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {
|
|||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</Providers>
|
||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</DomainContextProvider>
|
</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(() => {
|
useEffect(() => {
|
||||||
const statusToRedirect = ["idle", "recording", "processing"];
|
const statusToRedirect = ["idle", "recording", "processing"];
|
||||||
if (statusToRedirect.includes(transcript.response?.status)) {
|
if (statusToRedirect.includes(transcript.response?.status || "")) {
|
||||||
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
|
const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
|
||||||
// Shallow redirection does not work on NextJS 13
|
// Shallow redirection does not work on NextJS 13
|
||||||
// https://github.com/vercel/next.js/discussions/48110
|
// https://github.com/vercel/next.js/discussions/48110
|
||||||
@@ -55,7 +55,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
.replace(/ +/g, " ")
|
.replace(/ +/g, " ")
|
||||||
.trim() || "";
|
.trim() || "";
|
||||||
|
|
||||||
if (transcript && transcript.response) {
|
|
||||||
if (transcript.error || topics?.error) {
|
if (transcript.error || topics?.error) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -65,12 +64,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transcriptId || transcript?.loading || topics?.loading) {
|
if (transcript?.loading || topics?.loading) {
|
||||||
return <Modal title="Loading" text={"Loading transcript..."} />;
|
return <Modal title="Loading" text={"Loading transcript..."} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="grid grid-rows-layout-topbar h-full max-h-full gap-2 lg:gap-4">
|
||||||
{featureEnabled("sendToZulip") && (
|
{featureEnabled("sendToZulip") && (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
transcript={transcript.response}
|
transcript={transcript.response}
|
||||||
@@ -105,6 +104,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
topics={topics.topics || []}
|
topics={topics.topics || []}
|
||||||
useActiveTopic={useActiveTopic}
|
useActiveTopic={useActiveTopic}
|
||||||
autoscroll={false}
|
autoscroll={false}
|
||||||
|
transcriptId={transcriptId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
|
<div className="w-full h-full grid grid-rows-layout-one grid-cols-1 gap-2 lg:gap-4">
|
||||||
@@ -122,8 +122,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
<p>Loading Transcript</p>
|
<p>Loading Transcript</p>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
There was an error generating the final summary, please
|
There was an error generating the final summary, please come
|
||||||
come back later
|
back later
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,15 +140,14 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-grow max-w-full">
|
<div className="flex-grow max-w-full">
|
||||||
<ShareLink
|
<ShareLink
|
||||||
transcriptId={transcript?.response?.id}
|
transcriptId={transcript.response.id}
|
||||||
userId={transcript?.response?.user_id}
|
userId={transcript.response.user_id}
|
||||||
shareMode={toShareMode(transcript?.response?.share_mode)}
|
shareMode={toShareMode(transcript.response.share_mode)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
|||||||
|
|
||||||
//TODO if has no topic and is error, get back to new
|
//TODO if has no topic and is error, get back to new
|
||||||
if (
|
if (
|
||||||
statusToRedirect.includes(transcript.response?.status) ||
|
transcript.response?.status &&
|
||||||
statusToRedirect.includes(webSockets.status.value)
|
(statusToRedirect.includes(transcript.response?.status) ||
|
||||||
|
statusToRedirect.includes(webSockets.status.value))
|
||||||
) {
|
) {
|
||||||
const newUrl = "/transcripts/" + details.params.transcriptId;
|
const newUrl = "/transcripts/" + details.params.transcriptId;
|
||||||
// Shallow redirection does not work on NextJS 13
|
// Shallow redirection does not work on NextJS 13
|
||||||
@@ -86,7 +87,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full h-full">
|
||||||
{webSockets.waveform && webSockets.duration && mp3?.media ? (
|
{webSockets.waveform && webSockets.duration && mp3?.media ? (
|
||||||
<Player
|
<Player
|
||||||
topics={webSockets.topics || []}
|
topics={webSockets.topics || []}
|
||||||
@@ -119,6 +120,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
|||||||
topics={webSockets.topics}
|
topics={webSockets.topics}
|
||||||
useActiveTopic={useActiveTopic}
|
useActiveTopic={useActiveTopic}
|
||||||
autoscroll={true}
|
autoscroll={true}
|
||||||
|
transcriptId={details.params.transcriptId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -165,7 +167,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{disconnected && <DisconnectedIndicator />}
|
{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();
|
useAudioDevice();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 max-h-full overflow-y-scroll">
|
||||||
<div className="hidden lg:block"></div>
|
|
||||||
<div className="lg:grid lg:grid-cols-2 lg:grid-rows-1 lg:gap-4 lg:h-full h-auto flex flex-col">
|
<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">
|
<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">
|
<div className="flex flex-col max-w-xl items-center justify-center">
|
||||||
@@ -143,7 +142,7 @@ const TranscriptCreate = () => {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatTime } from "../../lib/time";
|
|||||||
import ScrollToBottom from "./scrollToBottom";
|
import ScrollToBottom from "./scrollToBottom";
|
||||||
import { Topic } from "./webSocketTypes";
|
import { Topic } from "./webSocketTypes";
|
||||||
import { generateHighContrastColor } from "../../lib/utils";
|
import { generateHighContrastColor } from "../../lib/utils";
|
||||||
|
import useParticipants from "./useParticipants";
|
||||||
|
|
||||||
type TopicListProps = {
|
type TopicListProps = {
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
@@ -16,15 +17,18 @@ type TopicListProps = {
|
|||||||
React.Dispatch<React.SetStateAction<Topic | null>>,
|
React.Dispatch<React.SetStateAction<Topic | null>>,
|
||||||
];
|
];
|
||||||
autoscroll: boolean;
|
autoscroll: boolean;
|
||||||
|
transcriptId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TopicList({
|
export function TopicList({
|
||||||
topics,
|
topics,
|
||||||
useActiveTopic,
|
useActiveTopic,
|
||||||
autoscroll,
|
autoscroll,
|
||||||
|
transcriptId,
|
||||||
}: TopicListProps) {
|
}: TopicListProps) {
|
||||||
const [activeTopic, setActiveTopic] = useActiveTopic;
|
const [activeTopic, setActiveTopic] = useActiveTopic;
|
||||||
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
|
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
|
||||||
|
const participants = useParticipants(transcriptId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoscroll && autoscrollEnabled) scrollToBottom();
|
if (autoscroll && autoscrollEnabled) scrollToBottom();
|
||||||
@@ -61,6 +65,15 @@ export function TopicList({
|
|||||||
}
|
}
|
||||||
}, [activeTopic, autoscroll]);
|
}, [activeTopic, autoscroll]);
|
||||||
|
|
||||||
|
const getSpeakerName = (speakerNumber: number) => {
|
||||||
|
if (!participants.response) return;
|
||||||
|
return (
|
||||||
|
participants.response.find(
|
||||||
|
(participant) => participant.speaker == speakerNumber,
|
||||||
|
)?.name || `Speaker ${speakerNumber}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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 ? (
|
{topics.length > 0 ? (
|
||||||
@@ -125,7 +138,7 @@ export function TopicList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
(Speaker {segment.speaker}):
|
{getSpeakerName(segment.speaker)}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span>{segment.text}</span>
|
<span>{segment.text}</span>
|
||||||
</p>
|
</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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useError } from "../../(errors)/errorContext";
|
import { useError } from "../../(errors)/errorContext";
|
||||||
import { Topic } from "./webSocketTypes";
|
import { Topic } from "./webSocketTypes";
|
||||||
import useApi from "../../lib/useApi";
|
import useApi from "../../lib/useApi";
|
||||||
import { shouldShowError } from "../../lib/errorUtils";
|
import { shouldShowError } from "../../lib/errorUtils";
|
||||||
|
import { GetTranscriptTopic } from "../../api";
|
||||||
|
|
||||||
type TranscriptTopics = {
|
type TranscriptTopics = {
|
||||||
topics: Topic[] | null;
|
topics: GetTranscriptTopic[] | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useTopics = (id: string): TranscriptTopics => {
|
const useTopics = (id: string): TranscriptTopics => {
|
||||||
const [topics, setTopics] = useState<Topic[] | null>(null);
|
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 [error, setErrorState] = useState<Error | null>(null);
|
||||||
const { setError } = useError();
|
const { setError } = useError();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || !api) return;
|
if (!id || !api) return;
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import useApi from "../../lib/useApi";
|
|||||||
type ErrorTranscript = {
|
type ErrorTranscript = {
|
||||||
error: Error;
|
error: Error;
|
||||||
loading: false;
|
loading: false;
|
||||||
response: any;
|
response: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoadingTranscript = {
|
type LoadingTranscript = {
|
||||||
response: any;
|
response: null;
|
||||||
loading: true;
|
loading: true;
|
||||||
error: false;
|
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[] = [
|
const supportedLanguages: LanguageOption[] = [
|
||||||
|
{
|
||||||
|
value: "",
|
||||||
|
name: "No translation",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "af",
|
value: "af",
|
||||||
name: "Afrikaans",
|
name: "Afrikaans",
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
"openapi": "openapi --input http://127.0.0.1:1250/openapi.json --name OpenApi --output app/api && yarn format"
|
"openapi": "openapi --input http://127.0.0.1:1250/openapi.json --name OpenApi --output app/api && yarn format"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@fief/fief": "^0.13.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
@@ -19,9 +26,11 @@
|
|||||||
"@vercel/edge-config": "^0.4.1",
|
"@vercel/edge-config": "^0.4.1",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"chakra-react-select": "^4.7.6",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "^14.0.4",
|
"eslint-config-next": "^14.0.4",
|
||||||
"fontawesome": "^5.6.3",
|
"fontawesome": "^5.6.3",
|
||||||
|
"framer-motion": "^10.16.16",
|
||||||
"jest-worker": "^29.6.2",
|
"jest-worker": "^29.6.2",
|
||||||
"next": "^14.0.4",
|
"next": "^14.0.4",
|
||||||
"postcss": "8.4.25",
|
"postcss": "8.4.25",
|
||||||
|
|||||||
@@ -9,12 +9,50 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
gridTemplateRows: {
|
gridTemplateRows: {
|
||||||
layout: "auto auto minmax(0, 1fr)",
|
"layout-topbar": "auto minmax(0,1fr)",
|
||||||
"mobile-inner": "minmax(0, 2fr) minmax(0, 1fr)",
|
"mobile-inner": "minmax(0, 2fr) minmax(0, 1fr)",
|
||||||
"layout-one": "minmax(0, 1fr) auto",
|
"layout-one": "minmax(0, 1fr) auto",
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"spin-slow": "spin 3s linear infinite",
|
"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: {
|
colors: {
|
||||||
bluegrey: "RGB(90, 122, 158)",
|
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