changes types, assign to participant instead of speaker

This commit is contained in:
Sara
2023-12-12 13:29:55 +01:00
parent d8c4f29d72
commit c02c3b190c
7 changed files with 312 additions and 224 deletions

View File

@@ -1,59 +1,77 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer";
import getApi from "../../../../lib/getApi";
import useParticipants from "../../useParticipants";
import useTopicWithWords from "../../useTopicWithWords";
import ParticipantList from "./participantList";
import { GetTranscriptTopic } from "../../../../api";
type TranscriptCorrect = {
export type TranscriptCorrect = {
params: {
transcriptId: string;
};
};
type TimeSlice = {
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"
);
}
export default function TranscriptCorrect(details: TranscriptCorrect) {
const transcriptId = details.params.transcriptId;
const transcript = useTranscript(transcriptId);
const [currentTopic, setCurrentTopic] = useState("");
const topicWithWords = useTopicWithWords(currentTopic, transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic;
const topicWithWords = useTopicWithWords(currentTopic?.id, transcriptId);
const [selectedTime, setSelectedTime] = useState<TimeSlice>();
const [topicTime, setTopicTime] = useState<TimeSlice>();
const participants = useParticipants(transcriptId);
const stateSelectedSpeaker = useState<number>();
const stateSelectedText = useState<SelectedText>();
const [selectedText, _sst] = stateSelectedText;
console.log(selectedText);
useEffect(() => {
if (currentTopic) {
setTopicTime({
start: currentTopic.timestamp,
end: currentTopic.timestamp + currentTopic.duration,
});
} else {
setTopicTime(undefined);
}
}, [currentTopic]);
// TODO BE
// Get one topic with words
// -> fix useTopicWithWords.ts
// Add start and end time of each topic in the topic list
// -> use full current topic instead of topicId here
// -> remove time calculation and setting from TopicHeader
// -> pass in topicTime to player directly
// Should we have participants by default, one for each speaker ?
// Creating a participant and a speaker ?
return (
<div className="h-full grid grid-cols-2 gap-4">
<div className="flex flex-col h-full">
<TopicHeader
currentTopic={currentTopic}
setCurrentTopic={setCurrentTopic}
stateCurrentTopic={stateCurrentTopic}
transcriptId={transcriptId}
/>
<TopicWords
setSelectedTime={setSelectedTime}
selectedTime={selectedTime}
setTopicTime={setTopicTime}
stateSelectedSpeaker={stateSelectedSpeaker}
stateSelectedText={stateSelectedText}
participants={participants}
topicWithWords={topicWithWords}
/>
@@ -61,16 +79,17 @@ export default function TranscriptCorrect(details: TranscriptCorrect) {
<div className="flex flex-col justify-stretch">
<TopicPlayer
transcriptId={transcriptId}
selectedTime={selectedTime}
selectedTime={
selectedTextIsTimeSlice(selectedText) ? selectedText : undefined
}
topicTime={topicTime}
/>
<ParticipantList
{...{
transcriptId,
participants,
selectedTime,
topicWithWords,
stateSelectedSpeaker,
stateSelectedText,
}}
/>
</div>

View File

@@ -4,28 +4,27 @@ import { useEffect, useRef, useState } from "react";
import { Participant } from "../../../../api";
import getApi from "../../../../lib/getApi";
import { UseParticipants } from "../../useParticipants";
import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./page";
type ParticipantList = {
participants: UseParticipants;
transcriptId: string;
selectedTime: any;
topicWithWords: any;
stateSelectedSpeaker: any;
stateSelectedText: any;
};
const ParticipantList = ({
transcriptId,
participants,
selectedTime,
topicWithWords,
stateSelectedSpeaker,
stateSelectedText,
}: ParticipantList) => {
const api = getApi();
const [loading, setLoading] = useState(false);
const [participantInput, setParticipantInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [selectedSpeaker, setSelectedSpeaker] = stateSelectedSpeaker;
const [selectedText, setSelectedText] = stateSelectedText;
const [selectedParticipant, setSelectedParticipant] = useState<Participant>();
const [action, setAction] = useState<
"Create" | "Create to rename" | "Create and assign" | "Rename" | null
@@ -40,10 +39,10 @@ const ParticipantList = ({
useEffect(() => {
if (participants.response) {
if (selectedSpeaker !== undefined) {
if (selectedTextIsSpeaker(selectedText)) {
inputRef.current?.focus();
const participant = participants.response.find(
(p) => p.speaker == selectedSpeaker,
(p) => p.speaker == selectedText,
);
if (participant) {
setParticipantInput(participant.name);
@@ -57,20 +56,23 @@ const ParticipantList = ({
setAction("Create to rename");
}
}
if (selectedTime) {
if (selectedTextIsTimeSlice(selectedText)) {
setParticipantInput("");
inputRef.current?.focus();
setAction("Create and assign");
setSelectedParticipant(undefined);
}
if (!selectedTime && !selectedSpeaker) {
if (typeof selectedText == undefined) {
setAction(null);
}
}
}, [selectedTime, selectedSpeaker]);
}, [selectedText]);
useEffect(() => {
if (participants.response && action == "Create and assign") {
if (
participants.response &&
(action == "Create and assign" || action == "Create to rename")
) {
if (
participants.response.filter((p) => p.name.startsWith(participantInput))
.length == 1
@@ -87,18 +89,23 @@ const ParticipantList = ({
if (participantInput && !action) {
setAction("Create");
}
if (!participantInput) {
setAction(null);
}
}, [participantInput]);
useEffect(() => {
document.onkeyup = (e) => {
if (e.key === "Enter" && e.ctrlKey) {
if (oneMatch) {
assignTo(oneMatch)();
setOneMatch(undefined);
setParticipantInput("");
if (action == "Create and assign") {
assignTo(oneMatch)();
setOneMatch(undefined);
setParticipantInput("");
} else if (
action == "Create to rename" &&
oneMatch &&
selectedTextIsSpeaker(selectedText)
) {
mergeSpeaker(selectedText, oneMatch)();
}
}
} else if (e.key === "Enter") {
doAction();
@@ -106,13 +113,36 @@ const ParticipantList = ({
};
});
const mergeSpeaker =
(speakerFrom, participantTo: Participant) => async () => {
if (participantTo.speaker) {
await api?.v1TranscriptMergeSpeaker({
transcriptId,
speakerMerge: {
speakerFrom: speakerFrom,
speakerTo: participantTo.speaker,
},
});
} else {
await api?.v1TranscriptUpdateParticipant({
transcriptId,
participantId: participantTo.id,
updateParticipant: { speaker: speakerFrom },
});
}
participants.refetch();
topicWithWords.refetch();
setAction(null);
setParticipantInput("");
};
const doAction = (e?) => {
e?.preventDefault();
e?.stopPropagation();
if (!participants.response) return;
if (action == "Rename") {
if (action == "Rename" && selectedTextIsSpeaker(selectedText)) {
const participant = participants.response.find(
(p) => p.speaker == selectedSpeaker,
(p) => p.speaker == selectedText,
);
if (participant && participant.name !== participantInput) {
api
@@ -127,14 +157,16 @@ const ParticipantList = ({
participants.refetch();
});
}
} else if (action == "Create to rename") {
} else if (
action == "Create to rename" &&
selectedTextIsSpeaker(selectedText)
) {
setLoading(true);
console.log(participantInput, selectedSpeaker);
api
?.v1TranscriptAddParticipant({
createParticipant: {
name: participantInput,
speaker: selectedSpeaker,
speaker: selectedText,
},
transcriptId,
})
@@ -142,13 +174,15 @@ const ParticipantList = ({
participants.refetch();
setParticipantInput("");
});
} else if (action == "Create and assign") {
} else if (
action == "Create and assign" &&
selectedTextIsTimeSlice(selectedText)
) {
setLoading(true);
api
?.v1TranscriptAddParticipant({
createParticipant: {
name: participantInput,
speaker: Math.floor(Math.random() * 100 + 10),
},
transcriptId,
})
@@ -191,14 +225,14 @@ const ParticipantList = ({
e?.preventDefault();
e?.stopPropagation();
// fix participant that doesnt have a speaker (wait API)
if (selectedTime?.start == undefined || selectedTime?.end == undefined)
return;
if (!selectedTextIsTimeSlice(selectedText)) return;
api
?.v1TranscriptAssignSpeaker({
speakerAssignment: {
speaker: participant.speaker,
timestampFrom: selectedTime.start,
timestampTo: selectedTime.end,
participant: participant.id,
timestampFrom: selectedText.start,
timestampTo: selectedText.end,
},
transcriptId,
})
@@ -209,82 +243,113 @@ const ParticipantList = ({
const selectParticipant = (participant) => (e) => {
setSelectedParticipant(participant);
setSelectedSpeaker(participant.speaker);
setSelectedText(participant.speaker);
setAction("Rename");
setParticipantInput(participant.name);
};
const clearSelection = () => {
setSelectedParticipant(undefined);
setSelectedText(undefined);
setAction(null);
};
const preventClick = (e) => {
e?.stopPropagation();
e?.preventDefault();
};
return (
<>
<div>
<input
ref={inputRef}
onChange={(e) => setParticipantInput(e.target.value)}
value={participantInput}
/>
{action && (
<button onClick={doAction}>
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 mr-2"
/>
{action}
</button>
<div className="h-full" onClick={clearSelection}>
<div onClick={preventClick}>
<div>
<input
ref={inputRef}
onChange={(e) => setParticipantInput(e.target.value)}
value={participantInput}
/>
{action && (
<button onClick={doAction}>
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 mr-2"
/>
{action}
</button>
)}
</div>
{participants.loading && (
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-300 h-8"
/>
)}
{participants.response && (
<ul>
{participants.response.map((participant: Participant) => (
<li
onClick={selectParticipant(participant)}
className={
"flex flex-row justify-between " +
(participantInput.length > 0 &&
selectedText &&
participant.name.startsWith(participantInput)
? "bg-blue-100 "
: "") +
(participant.id == selectedParticipant?.id
? "border-blue-400 border"
: "")
}
key={participant.id}
>
<span>{participant.name}</span>
<div>
{selectedTextIsSpeaker(selectedText) && !loading && (
<button onClick={mergeSpeaker(selectedText, participant)}>
{oneMatch &&
action == "Create to rename" &&
participant.name.startsWith(participantInput) && (
<>
{" "}
<span>CTRL + </span>{" "}
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 mr-2"
/>{" "}
</>
)}{" "}
Merge
</button>
)}
{selectedTextIsTimeSlice(selectedText) && !loading && (
<button onClick={assignTo(participant)}>
{oneMatch &&
action == "Create and assign" &&
participant.name.startsWith(participantInput) && (
<>
{" "}
<span>CTRL + </span>{" "}
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 mr-2"
/>{" "}
</>
)}{" "}
Assign
</button>
)}
<button onClick={deleteParticipant(participant.id)}>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
{participants.loading && (
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-300 h-8"
/>
)}
{participants.response && (
<ul>
{participants.response.map((participant: Participant) => (
<li
onClick={selectParticipant(participant)}
className={
"flex flex-row justify-between " +
(participantInput.length > 0 &&
selectedTime &&
participant.name.startsWith(participantInput)
? "bg-blue-100 "
: "") +
(participant.id == selectedParticipant?.id
? "border-blue-400 border"
: "")
}
key={participant.id}
>
<span>{participant.name}</span>
<div>
{selectedTime && !loading && (
<button onClick={assignTo(participant)}>
{oneMatch &&
participant.name.startsWith(participantInput) && (
<>
{" "}
<span>CTRL + </span>{" "}
<FontAwesomeIcon
icon={faArrowTurnDown}
className="rotate-90 mr-2"
/>{" "}
</>
)}{" "}
Assign
</button>
)}
<button onClick={deleteParticipant(participant.id)}>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</>
</div>
);
};

View File

@@ -1,32 +1,41 @@
import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import useTopics from "../../useTopics";
import { useEffect } from "react";
import { Dispatch, SetStateAction, useEffect } from "react";
import { TranscriptTopic } from "../../../../api/models/TranscriptTopic";
import { GetTranscriptTopic } from "../../../../api";
type TopicHeader = {
stateCurrentTopic: [
GetTranscriptTopic | undefined,
Dispatch<SetStateAction<GetTranscriptTopic | undefined>>,
];
transcriptId: string;
};
export default function TopicHeader({
currentTopic,
setCurrentTopic,
stateCurrentTopic,
transcriptId,
}) {
}: TopicHeader) {
const [currentTopic, setCurrentTopic] = stateCurrentTopic;
const topics = useTopics(transcriptId);
useEffect(() => {
if (!topics.loading && !currentTopic) {
setCurrentTopic(topics?.topics?.at(0)?.id);
setCurrentTopic(topics?.topics?.at(0));
}
}, [topics.loading]);
if (topics.topics) {
const title = topics.topics.find((topic) => topic.id == currentTopic)
?.title;
const number = topics.topics.findIndex((topic) => topic.id == currentTopic);
if (topics.topics && currentTopic) {
const number = topics.topics.findIndex(
(topic) => topic.id == currentTopic.id,
);
const canGoPrevious = number > 0;
const total = topics.topics.length;
const canGoNext = total && number < total + 1;
const onPrev = () =>
setCurrentTopic(topics.topics?.at(number - 1)?.id || "");
const onNext = () =>
setCurrentTopic(topics.topics?.at(number + 1)?.id || "");
const onPrev = () => setCurrentTopic(topics.topics?.at(number - 1));
const onNext = () => setCurrentTopic(topics.topics?.at(number + 1));
return (
<div className="flex flex-row">
@@ -40,7 +49,7 @@ export default function TopicHeader({
<FontAwesomeIcon icon={faArrowLeft} />
</button>
<h1 className="flex-grow">
{title}{" "}
{currentTopic.title}{" "}
<span>
{number + 1}/{total}
</span>

View File

@@ -1,68 +1,43 @@
import { SetStateAction, useCallback, useEffect, useState } from "react";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import WaveformLoading from "../../waveformLoading";
import { UseParticipants } from "../../useParticipants";
import { Participant } from "../../../../api";
type Word = {
end: number;
speaker: number;
start: number;
text: string;
};
type WordBySpeaker = { speaker: number; words: Word[] }[];
import { UseTopicWithWords } from "../../useTopicWithWords";
import { TimeSlice, selectedTextIsTimeSlice } from "./page";
// TODO shortcuts ?
// TODO fix key (using indexes might act up, not sure as we don't re-order per say)
type TopicWordsProps = {
setSelectedTime: SetStateAction<any>;
selectedTime: any;
setTopicTime: SetStateAction<any>;
stateSelectedSpeaker: any;
stateSelectedText: [
number | TimeSlice | undefined,
Dispatch<SetStateAction<number | TimeSlice | undefined>>,
];
participants: UseParticipants;
topicWithWords: any;
topicWithWords: UseTopicWithWords;
};
const topicWords = ({
setSelectedTime,
selectedTime,
setTopicTime,
stateSelectedSpeaker,
stateSelectedText,
participants,
topicWithWords,
}: TopicWordsProps) => {
const [wordsBySpeaker, setWordsBySpeaker] = useState<WordBySpeaker>();
const [selectedSpeaker, setSelectedSpeaker] = stateSelectedSpeaker;
const [selectedText, setSelectedText] = stateSelectedText;
useEffect(() => {
if (topicWithWords.loading) {
setWordsBySpeaker([]);
setSelectedTime(undefined);
// setWordsBySpeaker([]);
setSelectedText(undefined);
console.log("unsetting topic changed");
}
}, [topicWithWords.loading]);
useEffect(() => {
if (!topicWithWords.loading && !topicWithWords.error) {
const wordsFlat = topicWithWords.response.words as Word[];
const wordsSorted = wordsFlat.reduce((acc, curr) => {
if (acc.length > 0 && acc[acc.length - 1].speaker == curr.speaker) {
acc[acc.length - 1].words.push(curr);
return acc;
} else {
acc?.push({ speaker: curr.speaker, words: [curr] });
return acc;
}
}, [] as WordBySpeaker);
setWordsBySpeaker(wordsSorted);
setTopicTime({
start: wordsFlat.at(0)?.start,
end: wordsFlat.at(wordsFlat.length - 1)?.end,
});
}
}, [topicWithWords.response]);
const getStartTimeFromFirstNode = (node, offset, reverse) => {
// if the first element is a word
return node.parentElement?.dataset["start"]
@@ -91,7 +66,7 @@ const topicWords = ({
selection.anchorNode == selection.focusNode &&
selection.anchorOffset == selection.focusOffset
) {
setSelectedTime(undefined);
setSelectedText(undefined);
selection.empty();
return;
}
@@ -114,9 +89,11 @@ const topicWords = ({
!focusIsWord &&
anchorNode.parentElement == focusNode.parentElement
) {
setSelectedSpeaker(focusNode.parentElement?.dataset["speaker"]);
setSelectedTime(undefined);
selection.empty();
setSelectedText(
focusNode.parentElement?.dataset["speaker"]
? parseInt(focusNode.parentElement?.dataset["speaker"])
: undefined,
);
console.log("Unset Time : selected Speaker");
return;
}
@@ -139,7 +116,7 @@ const topicWords = ({
const reverse = parseFloat(anchorStart) > parseFloat(focusEnd);
if (!reverse) {
setSelectedTime({
setSelectedText({
start: parseFloat(anchorStart),
end: parseFloat(focusEnd),
});
@@ -158,13 +135,14 @@ const topicWords = ({
true,
);
setSelectedTime({ start: focusStart, end: anchorEnd });
setSelectedText({
start: parseFloat(focusStart),
end: parseFloat(anchorEnd),
});
console.log("setting reverse");
}
setSelectedSpeaker();
selection.empty();
}
selection && selection.empty();
};
const getSpeakerName = (speakerNumber: number) => {
@@ -176,39 +154,45 @@ const topicWords = ({
);
};
if (!topicWithWords.loading && wordsBySpeaker && participants.response) {
if (
!topicWithWords.loading &&
topicWithWords.response &&
participants.response
) {
return (
<div onMouseUp={onMouseUp} className="p-5 h-full w-full">
{wordsBySpeaker?.map((speakerWithWords, index) => (
<p key={index}>
<span
data-speaker={speakerWithWords.speaker}
className={
selectedSpeaker == speakerWithWords.speaker
? "bg-yellow-200"
: ""
}
>
{getSpeakerName(speakerWithWords.speaker)}&nbsp;:&nbsp;
</span>
{speakerWithWords.words.map((word, index) => (
{topicWithWords.response.wordsPerSpeaker.map(
(speakerWithWords, index) => (
<p key={index}>
<span
data-start={word.start}
data-end={word.end}
key={index}
data-speaker={speakerWithWords.speaker}
className={
selectedTime &&
selectedTime.start <= word.start &&
selectedTime.end >= word.end
selectedText == speakerWithWords.speaker
? "bg-yellow-200"
: ""
}
>
{word.text}
{getSpeakerName(speakerWithWords.speaker)}&nbsp;:&nbsp;
</span>
))}
</p>
))}
{speakerWithWords.words.map((word, index) => (
<span
data-start={word.start}
data-end={word.end}
key={index}
className={
selectedTextIsTimeSlice(selectedText) &&
selectedText.start <= word.start &&
selectedText.end >= word.end
? "bg-yellow-200"
: ""
}
>
{word.text}
</span>
))}
</p>
),
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@
"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 ?",

View File

@@ -1,6 +1,13 @@
import { useEffect, useState } from "react";
import { V1TranscriptGetTopicsWithWordsRequest } from "../../api/apis/DefaultApi";
import { GetTranscript, GetTranscriptTopicWithWords } from "../../api";
import {
V1TranscriptGetTopicsWithWordsPerSpeakerRequest,
V1TranscriptGetTopicsWithWordsRequest,
} from "../../api/apis/DefaultApi";
import {
GetTranscript,
GetTranscriptTopicWithWords,
GetTranscriptTopicWithWordsPerSpeaker,
} from "../../api";
import { useError } from "../../(errors)/errorContext";
import getApi from "../../lib/getApi";
import { shouldShowError } from "../../lib/errorUtils";
@@ -8,22 +15,22 @@ import { shouldShowError } from "../../lib/errorUtils";
type ErrorTopicWithWords = {
error: Error;
loading: false;
response: any;
response: null;
};
type LoadingTopicWithWords = {
response: any;
response: GetTranscriptTopicWithWordsPerSpeaker | null;
loading: true;
error: false;
};
type SuccessTopicWithWords = {
response: GetTranscriptTopicWithWords;
response: GetTranscriptTopicWithWordsPerSpeaker;
loading: false;
error: null;
};
type UseTopicWithWords = { refetch: () => void } & (
export type UseTopicWithWords = { refetch: () => void } & (
| ErrorTopicWithWords
| LoadingTopicWithWords
| SuccessTopicWithWords
@@ -33,7 +40,8 @@ const useTopicWithWords = (
topicId: string | null,
transcriptId: string,
): UseTopicWithWords => {
const [response, setResponse] = useState<GetTranscript | null>(null);
const [response, setResponse] =
useState<GetTranscriptTopicWithWordsPerSpeaker | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
@@ -55,13 +63,14 @@ const useTopicWithWords = (
if (!transcriptId || !topicId || !api) return;
setLoading(true);
const requestParameters: V1TranscriptGetTopicsWithWordsRequest = {
const requestParameters: V1TranscriptGetTopicsWithWordsPerSpeakerRequest = {
transcriptId,
topicId,
};
api
.v1TranscriptGetTopicsWithWords(requestParameters)
.v1TranscriptGetTopicsWithWordsPerSpeaker(requestParameters)
.then((result) => {
setResponse(result.find((topic) => topic.id == topicId));
setResponse(result);
setLoading(false);
console.debug("Topics with words Loaded:", result);
})

View File

@@ -5,9 +5,10 @@ import { Topic } from "./webSocketTypes";
import getApi from "../../lib/getApi";
import { shouldShowError } from "../../lib/errorUtils";
import mockTopics from "./mockTopics.json";
import { GetTranscriptTopic } from "../../api";
type TranscriptTopics = {
topics: Topic[] | null;
topics: GetTranscriptTopic[] | null;
loading: boolean;
error: Error | null;
};