player and share

This commit is contained in:
Sara
2024-01-24 13:57:16 +01:00
parent 66211262d0
commit 68e708f62b
14 changed files with 516 additions and 398 deletions

View File

@@ -2,41 +2,50 @@ import { useEffect, useRef, useState } from "react";
import React from "react"; import React from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import "../../../styles/markdown.css"; import "../../../styles/markdown.css";
import { UpdateTranscript } from "../../../api"; import {
GetTranscript,
GetTranscriptTopic,
UpdateTranscript,
} from "../../../api";
import useApi from "../../../lib/useApi"; import useApi from "../../../lib/useApi";
import useTranscript from "../useTranscript"; import {
import useTopics from "../useTopics"; Flex,
import { Box, Flex, IconButton, Modal, ModalContent } from "@chakra-ui/react"; Heading,
import { FaShare } from "react-icons/fa"; IconButton,
import ShareTranscript from "../shareTranscript"; Button,
Textarea,
Spacer,
} from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
type FinalSummaryProps = { type FinalSummaryProps = {
transcriptId: string; transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
}; };
export default function FinalSummary(props: FinalSummaryProps) { export default function FinalSummary(props: FinalSummaryProps) {
const transcript = useTranscript(props.transcriptId);
const topics = useTopics(props.transcriptId);
const finalSummaryRef = useRef<HTMLParagraphElement>(null); const finalSummaryRef = useRef<HTMLParagraphElement>(null);
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState(""); const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState(""); const [editedSummary, setEditedSummary] = useState("");
const [showShareModal, setShowShareModal] = useState(false); const api = useApi();
const { setError } = useError();
useEffect(() => { useEffect(() => {
setEditedSummary(transcript.response?.long_summary || ""); setEditedSummary(props.transcriptResponse?.long_summary || "");
}, [transcript.response?.long_summary]); }, [props.transcriptResponse?.long_summary]);
if (!topics.topics || !transcript.response) { if (!props.topicsResponse || !props.transcriptResponse) {
return null; return null;
} }
const updateSummary = async (newSummary: string, transcriptId: string) => { const updateSummary = async (newSummary: string, transcriptId: string) => {
try { try {
const api = useApi();
const requestBody: UpdateTranscript = { const requestBody: UpdateTranscript = {
long_summary: newSummary, long_summary: newSummary,
}; };
@@ -47,6 +56,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
console.log("Updated long summary:", updatedTranscript); console.log("Updated long summary:", updatedTranscript);
} catch (err) { } catch (err) {
console.error("Failed to update long summary:", err); console.error("Failed to update long summary:", err);
setError(err, "Failed to update long summary.");
} }
}; };
@@ -61,7 +71,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
}; };
const onSaveClick = () => { const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptId); updateSummary(editedSummary, props.transcriptResponse.id);
setIsEditMode(false); setIsEditMode(false);
}; };
@@ -77,86 +87,64 @@ export default function FinalSummary(props: FinalSummaryProps) {
}; };
return ( return (
<div <Flex
className={ direction="column"
(isEditMode ? "overflow-y-none" : "overflow-y-auto") + maxH={"100%"}
" max-h-full flex flex-col h-full" h={"100%"}
} overflowY={isEditMode ? "hidden" : "auto"}
pb={4}
> >
<div className="flex flex-row flex-wrap-reverse justify-between items-center"> <Flex dir="row" justify="start" align="center" wrap={"wrap-reverse"}>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold"> <Heading size={{ base: "md" }}>Summary</Heading>
Final Summary
</h2>
<div className="ml-auto flex space-x-2 mb-2"> {isEditMode && (
{isEditMode && ( <>
<> <Spacer />
<button <Button
onClick={onDiscardClick} onClick={onDiscardClick}
className={"text-gray-500 text-sm hover:underline"} colorScheme="gray"
> variant={"text"}
Discard Changes >
</button> Discard
<button </Button>
onClick={onSaveClick} <Button onClick={onSaveClick} colorScheme="blue">
className={ Save
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2" </Button>
} </>
> )}
Save Changes
</button>
</>
)}
{!isEditMode && ( {!isEditMode && (
<> <>
<button <IconButton
onClick={onEditClick} icon={<FaPen />}
className={ aria-label="Edit Summary"
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base" onClick={onEditClick}
} />
> <Spacer />
<span className="text-xs"> Summary</span> <ShareAndPrivacy
</button> finalSummaryRef={finalSummaryRef}
<IconButton transcriptResponse={props.transcriptResponse}
icon={<FaShare />} topicsResponse={props.topicsResponse}
onClick={() => setShowShareModal(true)} />
aria-label="Share" </>
/> )}
{showShareModal && ( </Flex>
<Modal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
size="xl"
>
<ModalContent>
<ShareTranscript
finalSummaryRef={finalSummaryRef}
transcriptResponse={transcript.response}
topicsResponse={topics.topics}
/>
</ModalContent>
</Modal>
)}
</>
)}
</div>
</div>
{isEditMode ? ( {isEditMode ? (
<div className="flex-grow overflow-y-none"> <Textarea
<textarea value={editedSummary}
value={editedSummary} onChange={(e) => setEditedSummary(e.target.value)}
onChange={(e) => setEditedSummary(e.target.value)} className="markdown"
className="markdown w-full h-full d-block p-2 border rounded shadow-sm" onKeyDown={(e) => handleTextAreaKeyDown(e)}
onKeyDown={(e) => handleTextAreaKeyDown(e)} h={"100%"}
/> resize={"none"}
</div> mt={2}
/>
) : ( ) : (
<div ref={finalSummaryRef} className="markdown"> <div ref={finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown> <Markdown>{editedSummary}</Markdown>
</div> </div>
)} )}
</div> </Flex>
); );
} }

View File

@@ -9,24 +9,11 @@ import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import "../../../styles/button.css"; import "../../../styles/button.css";
import FinalSummary from "./finalSummary"; import FinalSummary from "./finalSummary";
import ShareLink from "../shareLink";
import QRCode from "react-qr-code";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import ShareModal from "./shareModal";
import Player from "../player"; import Player from "../player";
import WaveformLoading from "../waveformLoading"; import WaveformLoading from "../waveformLoading";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { featureEnabled } from "../../domainContext"; import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import {
Box,
Button,
Flex,
Grid,
GridItem,
IconButton,
Text,
} from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -82,7 +69,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
templateRows="auto minmax(0, 1fr)" templateRows="auto minmax(0, 1fr)"
gap={2} gap={2}
padding={4} padding={4}
background="gray.100" paddingBottom={0}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8} borderRadius={8}
> >
<GridItem <GridItem
@@ -102,9 +92,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
autoscroll={false} autoscroll={false}
transcriptId={transcriptId} transcriptId={transcriptId}
/> />
{transcript.response.long_summary ? ( {transcript.response && topics.topics ? (
<> <>
<FinalSummary transcriptId={transcript.response.id} /> <FinalSummary
transcriptResponse={transcript.response}
topicsResponse={topics.topics}
/>
</> </>
) : ( ) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}> <Flex justify={"center"} alignItems={"center"} h={"100%"}>
@@ -121,9 +114,9 @@ export default function TranscriptDetails(details: TranscriptDetails) {
</Flex> </Flex>
)} )}
</Grid> </Grid>
{waveform.waveform && mp3.media ? ( {waveform.waveform && mp3.media && topics.topics ? (
<Player <Player
topics={topics?.topics || []} topics={topics?.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform.waveform} waveform={waveform.waveform}
media={mp3.media} media={mp3.media}
@@ -132,7 +125,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
) : waveform.error ? ( ) : waveform.error ? (
<div>"error loading this recording"</div> <div>"error loading this recording"</div>
) : ( ) : (
<WaveformLoading /> <Skeleton h={14} />
)} )}
</Grid> </Grid>
</> </>

View File

@@ -7,6 +7,9 @@ import { formatTime } from "../../lib/time";
import { Topic } from "./webSocketTypes"; import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api"; import { AudioWaveform } from "../../api";
import { waveSurferStyles } from "../../styles/recorder"; import { waveSurferStyles } from "../../styles/recorder";
import { Box, Flex, IconButton } from "@chakra-ui/react";
import PlayIcon from "../../styles/icons/play";
import PauseIcon from "../../styles/icons/pause";
type PlayerProps = { type PlayerProps = {
topics: Topic[]; topics: Topic[];
@@ -27,25 +30,32 @@ export default function Player(props: PlayerProps) {
const [waveRegions, setWaveRegions] = useState<RegionsPlugin | null>(null); const [waveRegions, setWaveRegions] = useState<RegionsPlugin | null>(null);
const [activeTopic, setActiveTopic] = props.useActiveTopic; const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics); const topicsRef = useRef(props.topics);
const [firstRender, setFirstRender] = useState<boolean>(true);
const keyHandler = (e) => {
if (e.key == " ") {
wavesurfer?.playPause();
}
};
useEffect(() => {
document.addEventListener("keyup", keyHandler);
return () => {
document.removeEventListener("keyup", keyHandler);
};
});
// Waveform setup // Waveform setup
useEffect(() => { useEffect(() => {
if (waveformRef.current) { if (waveformRef.current) {
// XXX duration is required to prevent recomputing peaks from audio
// However, the current waveform returns only the peaks, and no duration
// And the backend does not save duration properly.
// So at the moment, we deduct the duration from the topics.
// This is not ideal, but it works for now.
const _wavesurfer = WaveSurfer.create({ const _wavesurfer = WaveSurfer.create({
container: waveformRef.current, container: waveformRef.current,
peaks: props.waveform.data, peaks: [props.waveform.data],
hideScrollbar: true,
autoCenter: true,
barWidth: 2,
height: "auto", height: "auto",
duration: Math.floor(props.mediaDuration / 1000), duration: Math.floor(props.mediaDuration / 1000),
media: props.media, media: props.media,
...waveSurferStyles.player, ...waveSurferStyles.playerSettings,
}); });
// styling // styling
@@ -64,6 +74,7 @@ export default function Player(props: PlayerProps) {
_wavesurfer.on("timeupdate", setCurrentTime); _wavesurfer.on("timeupdate", setCurrentTime);
setWaveRegions(_wavesurfer.registerPlugin(RegionsPlugin.create())); setWaveRegions(_wavesurfer.registerPlugin(RegionsPlugin.create()));
// renderMarkers();
_wavesurfer.toggleInteraction(true); _wavesurfer.toggleInteraction(true);
@@ -85,7 +96,14 @@ export default function Player(props: PlayerProps) {
useEffect(() => { useEffect(() => {
topicsRef.current = props.topics; topicsRef.current = props.topics;
renderMarkers(); if (firstRender) {
setFirstRender(false);
setTimeout(() => {
renderMarkers();
}, 300);
} else {
renderMarkers();
}
}, [props.topics, waveRegions]); }, [props.topics, waveRegions]);
const renderMarkers = () => { const renderMarkers = () => {
@@ -96,22 +114,28 @@ export default function Player(props: PlayerProps) {
for (let topic of topicsRef.current) { for (let topic of topicsRef.current) {
const content = document.createElement("div"); const content = document.createElement("div");
content.setAttribute("style", waveSurferStyles.marker); content.setAttribute("style", waveSurferStyles.marker);
content.onmouseover = () => { content.onmouseover = (e) => {
content.style.backgroundColor = content.style.backgroundColor =
waveSurferStyles.markerHover.backgroundColor; waveSurferStyles.markerHover.backgroundColor;
content.style.zIndex = "999";
content.style.width = "300px"; content.style.width = "300px";
if (content.parentElement) {
content.parentElement.style.zIndex = "999";
}
}; };
content.onmouseout = () => { content.onmouseout = () => {
content.setAttribute("style", waveSurferStyles.marker); content.setAttribute("style", waveSurferStyles.marker);
if (content.parentElement) {
content.parentElement.style.zIndex = "0";
}
}; };
content.textContent = topic.title; content.textContent = topic.title;
const region = waveRegions.addRegion({ const region = waveRegions.addRegion({
start: topic.timestamp, start: topic.timestamp,
content, content,
color: "f00",
drag: false, drag: false,
resize: false,
top: 0,
}); });
region.on("click", (e) => { region.on("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -132,32 +156,39 @@ export default function Player(props: PlayerProps) {
}; };
const timeLabel = () => { const timeLabel = () => {
if (props.mediaDuration) if (props.mediaDuration && Math.floor(props.mediaDuration / 1000) > 0)
return `${formatTime(currentTime)}/${formatTime(props.mediaDuration)}`; return `${formatTime(currentTime)}/${formatTime(
Math.floor(props.mediaDuration / 1000),
)}`;
return ""; return "";
}; };
return ( return (
<div className="flex items-center w-full relative"> <Flex className="flex items-center w-full relative">
<div className="flex-grow items-end relative"> <IconButton
<div aria-label={isPlaying ? "Pause" : "Play"}
ref={waveformRef} icon={isPlaying ? <PauseIcon /> : <PlayIcon />}
className="flex-grow rounded-lg md:rounded-xl h-20" variant={"ghost"}
></div> colorScheme={"blue"}
<div className="absolute right-2 bottom-0">{timeLabel()}</div> mr={2}
</div>
<button
className={`${
isPlaying
? "bg-orange-400 hover:bg-orange-500 focus-visible:bg-orange-500"
: "bg-green-400 hover:bg-green-500 focus-visible:bg-green-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
id="play-btn" id="play-btn"
onClick={handlePlayClick} onClick={handlePlayClick}
> />
{isPlaying ? "Pause" : "Play"}
</button> <Box position="relative" flex={1}>
</div> <Box ref={waveformRef} height={14}></Box>
<Box
zIndex={50}
backgroundColor="rgba(255, 255, 255, 0.5)"
fontSize={"sm"}
shadow={"0px 0px 4px 0px white"}
position={"absolute"}
right={0}
bottom={0}
>
{timeLabel()}
</Box>
</Box>
</Flex>
); );
} }

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { featureEnabled } from "../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import { GetTranscript, GetTranscriptTopic, UpdateTranscript } from "../../api";
import {
Box,
Heading,
IconButton,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
} from "@chakra-ui/react";
import { FaShare } from "react-icons/fa";
import { useFiefUserinfo } from "@fief/fief/build/esm/nextjs/react";
import useApi from "../../lib/useApi";
import { Select } from "chakra-react-select";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
type ShareAndPrivacyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
type ShareOption = { value: ShareMode; label: string };
const shareOptions = [
{ label: "Private", value: toShareMode("private") },
{ label: "Secure", value: toShareMode("semi-private") },
{ label: "Public", value: toShareMode("public") },
];
export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [showModal, setShowModal] = useState(false);
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>(
shareOptions.find(
(option) => option.value === props.transcriptResponse.share_mode,
) || shareOptions[0],
);
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const api = useApi();
const updateShareMode = async (selectedShareMode: any) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode.value),
};
const updatedTranscript = await api.v1TranscriptUpdate(
props.transcriptResponse.id,
requestBody,
);
setShareMode(
shareOptions.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptions[0],
);
setShareLoading(false);
};
const userId = useFiefUserinfo()?.sub;
useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
}, [userId, props.transcriptResponse.user_id]);
return (
<>
<IconButton
icon={<FaShare />}
onClick={() => setShowModal(true)}
aria-label="Share"
/>
<Modal
isOpen={!!showModal}
onClose={() => setShowModal(false)}
size={"xl"}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Share</ModalHeader>
<ModalBody>
{requireLogin && (
<Box mb={4}>
<Text size="sm" mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text size="sm" mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
{isOwner && api && (
<Select
options={
[
{ value: "private", label: "Private" },
{ label: "Secure", value: "semi-private" },
{ label: "Public", value: "public" },
] as any
}
value={shareMode}
onChange={updateShareMode}
isLoading={shareLoading}
/>
)}
</Box>
)}
<Text size="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
{!requireLogin ||
(shareMode.value !== "private" && (
<ShareZulip
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
))}
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
mb={2}
/>
<ShareLink transcriptId={props.transcriptResponse.id} />
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
export default function ShareCopy({
finalSummaryRef,
transcriptResponse,
topicsResponse,
...boxProps
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => {
let text_to_copy = finalSummaryRef.current?.innerText;
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000);
});
};
const onCopyTranscriptClick = () => {
let text_to_copy =
topicsResponse
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")
.trim() || "";
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedTranscript(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedTranscript(false), 2000);
});
};
return (
<Box {...boxProps}>
<Button
onClick={onCopyTranscriptClick}
colorScheme="blue"
size={"sm"}
mr={2}
>
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</Button>
<Button onClick={onCopySummaryClick} colorScheme="blue" size={"sm"}>
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</Button>
</Box>
);
}

View File

@@ -1,20 +1,10 @@
import React, { useState, useRef, useEffect, use } from "react"; import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../domainContext"; import { featureEnabled } from "../domainContext";
import { useFiefUserinfo } from "@fief/fief/nextjs/react"; import { Button, Flex, Input, Text } from "@chakra-ui/react";
import SelectSearch from "react-select-search"; import QRCode from "react-qr-code";
import "react-select-search/style.css";
import "../../styles/button.css";
import "../../styles/form.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { UpdateTranscript } from "../../api";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import useApi from "../../lib/useApi";
type ShareLinkProps = { type ShareLinkProps = {
transcriptId: string; transcriptId: string;
transcriptUserId: string | null;
shareMode: ShareMode;
}; };
const ShareLink = (props: ShareLinkProps) => { const ShareLink = (props: ShareLinkProps) => {
@@ -22,20 +12,12 @@ const ShareLink = (props: ShareLinkProps) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [currentUrl, setCurrentUrl] = useState<string>(""); const [currentUrl, setCurrentUrl] = useState<string>("");
const requireLogin = featureEnabled("requireLogin"); const requireLogin = featureEnabled("requireLogin");
const [isOwner, setIsOwner] = useState(false); const privacyEnabled = featureEnabled("privacy");
const [shareMode, setShareMode] = useState<ShareMode>(props.shareMode);
const [shareLoading, setShareLoading] = useState(false);
const api = useApi();
const userId = useFiefUserinfo()?.sub;
useEffect(() => { useEffect(() => {
setCurrentUrl(window.location.href); setCurrentUrl(window.location.href);
}, []); }, []);
useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptUserId));
}, [userId, props.transcriptUserId]);
const handleCopyClick = () => { const handleCopyClick = () => {
if (inputRef.current) { if (inputRef.current) {
let text_to_copy = inputRef.current.value; let text_to_copy = inputRef.current.value;
@@ -49,105 +31,43 @@ const ShareLink = (props: ShareLinkProps) => {
} }
}; };
const updateShareMode = async (selectedShareMode: string) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode),
};
const updatedTranscript = await api.v1TranscriptUpdate(
props.transcriptId,
requestBody,
);
setShareMode(toShareMode(updatedTranscript.share_mode));
setShareLoading(false);
};
const privacyEnabled = featureEnabled("privacy");
return ( return (
<div <>
className="p-2 md:p-4 rounded"
style={{ background: "rgba(96, 165, 250, 0.2)" }}
>
{requireLogin && (
<div className="text-sm mb-2">
{shareMode === "private" && (
<p>This transcript is private and can only be accessed by you.</p>
)}
{shareMode === "semi-private" && (
<p>
This transcript is secure. Only authenticated users can access it.
</p>
)}
{shareMode === "public" && (
<p>This transcript is public. Everyone can access it.</p>
)}
{isOwner && api && (
<div className="relative">
<SelectSearch
className="select-search--top select-search"
options={[
{ name: "Private", value: "private" },
{ name: "Secure", value: "semi-private" },
{ name: "Public", value: "public" },
]}
value={shareMode?.toString()}
onChange={updateShareMode}
closeOnSelect={true}
/>
{shareLoading && (
<div className="h-4 w-4 absolute top-1/3 right-3 z-10">
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-4 w-4"
/>
</div>
)}
</div>
)}
</div>
)}
{!requireLogin && ( {!requireLogin && (
<> <>
{privacyEnabled ? ( {privacyEnabled ? (
<p className="text-sm mb-2"> <Text>
Share this link to grant others access to this page. The link Share this link to grant others access to this page. The link
includes the full audio recording and is valid for the next 7 includes the full audio recording and is valid for the next 7
days. days.
</p> </Text>
) : ( ) : (
<p className="text-sm mb-2"> <Text>
Share this link to allow others to view this page and listen to Share this link to allow others to view this page and listen to
the full audio recording. the full audio recording.
</p> </Text>
)} )}
</> </>
)} )}
<div className="flex items-center"> <Flex align={"center"}>
<input <QRCode
value={`${location.origin}/transcripts/${props.transcriptId}`}
level="L"
size={98}
/>
<Input
type="text" type="text"
readOnly readOnly
value={currentUrl} value={currentUrl}
ref={inputRef} ref={inputRef}
onChange={() => {}} onChange={() => {}}
className="border rounded-lg md:rounded-xl p-2 flex-grow flex-shrink overflow-auto mr-2 text-sm bg-slate-100 outline-slate-400" mx="2"
/> />
<button <Button onClick={handleCopyClick} colorScheme="blue">
onClick={handleCopyClick}
className={
(isCopied ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2"
}
style={{ minHeight: "38px" }}
>
{isCopied ? "Copied!" : "Copy"} {isCopied ? "Copied!" : "Copy"}
</button> </Button>
</div> </Flex>
</div> </>
); );
}; };

View File

@@ -1,108 +0,0 @@
import { useState } from "react";
import { featureEnabled } from "../domainContext";
import ShareModal from "./[transcriptId]/shareModal";
import ShareLink from "./shareLink";
import QRCode from "react-qr-code";
import { toShareMode } from "../../lib/shareMode";
import { GetTranscript, GetTranscriptTopic } from "../../api";
type ShareTranscriptProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
export default function ShareTranscript(props: ShareTranscriptProps) {
const [showModal, setShowModal] = useState(false);
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => {
let text_to_copy = props.finalSummaryRef.current?.innerText;
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000);
});
};
const onCopyTranscriptClick = () => {
let text_to_copy =
props.topicsResponse
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")
.trim() || "";
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedTranscript(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedTranscript(false), 2000);
});
};
return (
<div>
<>
{featureEnabled("sendToZulip") && (
<button
className={
"bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
onClick={() => setShowModal(true)}
>
<span className="text-xs"> Zulip</span>
</button>
)}
<button
onClick={onCopyTranscriptClick}
className={
(isCopiedTranscript ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
style={{ minHeight: "30px" }}
>
<span className="text-xs">
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</span>
</button>
<button
onClick={onCopySummaryClick}
className={
(isCopiedSummary ? "bg-blue-500" : "bg-blue-400") +
" hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded p-2 sm:text-base"
}
style={{ minHeight: "30px" }}
>
<span className="text-xs">
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</span>
</button>
{featureEnabled("sendToZulip") && (
<ShareModal
transcript={props.transcriptResponse}
topics={props.topicsResponse}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
)}
<QRCode
value={`${location.origin}/transcripts/${props.transcriptResponse.id}`}
level="L"
size={98}
/>
<ShareLink
transcriptId={props.transcriptResponse.id}
transcriptUserId={props.transcriptResponse.user_id}
shareMode={toShareMode(props.transcriptResponse.share_mode)}
/>
</>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useState } from "react";
import { featureEnabled } from "../domainContext";
import ShareModal from "./[transcriptId]/shareModal";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { Button } from "@chakra-ui/react";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
};
export default function ShareZulip(props: ShareZulipProps) {
const [showModal, setShowModal] = useState(false);
if (!featureEnabled("sendToZulip")) return null;
return (
<>
<Button colorScheme="blue" onClick={() => setShowModal(true)}>
Zulip
</Button>
<ShareModal
transcript={props.transcriptResponse}
topics={props.topicsResponse}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
</>
);
}

View File

@@ -35,16 +35,24 @@ export function TopicList({
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true); const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
const participants = useParticipants(transcriptId); const participants = useParticipants(transcriptId);
useEffect(() => { const scrollToTopic = () => {
if (autoscroll && autoscrollEnabled) scrollToBottom(); const topicDiv = document.getElementById(
}, [topics.length]); `accordion-button-topic-${activeTopic?.id}`,
);
const scrollToBottom = () => { setTimeout(() => {
const topicsDiv = document.getElementById("topics-div"); topicDiv?.scrollIntoView({
behavior: "smooth",
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight; block: "start",
inline: "nearest",
});
}, 200);
}; };
useEffect(() => {
if (activeTopic) scrollToTopic();
}, [activeTopic]);
// scroll top is not rounded, heights are, so exact match won't work. // scroll top is not rounded, heights are, so exact match won't work.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const toggleScroll = (element) => { const toggleScroll = (element) => {
@@ -70,6 +78,16 @@ export function TopicList({
} }
}, [activeTopic, autoscroll]); }, [activeTopic, autoscroll]);
useEffect(() => {
if (autoscroll && autoscrollEnabled) scrollToBottom();
}, [topics.length]);
const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div");
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const getSpeakerName = (speakerNumber: number) => { const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return; if (!participants.response) return;
return ( return (
@@ -102,9 +120,7 @@ export function TopicList({
overflowY={"auto"} overflowY={"auto"}
h={"100%"} h={"100%"}
onScroll={handleScroll} onScroll={handleScroll}
defaultIndex={[ index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
topics.findIndex((topic) => topic.id == activeTopic?.id),
]}
variant="custom" variant="custom"
allowToggle allowToggle
> >
@@ -117,12 +133,16 @@ export function TopicList({
_focus: "gray.100", _focus: "gray.100",
}} }}
padding={2} padding={2}
onClick={() => { id={`topic-${topic.id}`}
setActiveTopic(activeTopic?.id == topic.id ? null : topic);
}}
> >
<Flex dir="row" letterSpacing={".2"}> <Flex dir="row" letterSpacing={".2"}>
<AccordionButton> <AccordionButton
onClick={() => {
setActiveTopic(
activeTopic?.id == topic.id ? null : topic,
);
}}
>
<AccordionIcon /> <AccordionIcon />
<Box as="span" textAlign="left" ml="1"> <Box as="span" textAlign="left" ml="1">
{topic.title}{" "} {topic.title}{" "}

View File

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

View File

@@ -0,0 +1,14 @@
import { Icon } from "@chakra-ui/react";
export default function PauseIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.514 5.5C11.514 4.11929 10.3947 3 9.01404 3C7.63333 3 6.51404 4.11929 6.51404 5.5V24.5C6.51404 25.8807 7.63333 27 9.01404 27C10.3947 27 11.514 25.8807 11.514 24.5L11.514 5.5ZM23.486 5.5C23.486 4.11929 22.3667 3 20.986 3C19.6053 3 18.486 4.11929 18.486 5.5L18.486 24.5C18.486 25.8807 19.6053 27 20.986 27C22.3667 27 23.486 25.8807 23.486 24.5V5.5Z"
/>
</Icon>
);
}

View File

@@ -0,0 +1,12 @@
import { Icon } from "@chakra-ui/react";
export default function PlayIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
d="M27 13.2679C28.3333 14.0377 28.3333 15.9622 27 16.732L10.5 26.2583C9.16666 27.0281 7.5 26.0659 7.5 24.5263L7.5 5.47372C7.5 3.93412 9.16667 2.97187 10.5 3.74167L27 13.2679Z"
/>
</Icon>
);
}

View File

@@ -1,20 +1,26 @@
import { theme } from "@chakra-ui/react";
export const waveSurferStyles = { export const waveSurferStyles = {
playerSettings: { playerSettings: {
waveColor: "#777", waveColor: theme.colors.blue[500],
progressColor: "#222", progressColor: theme.colors.blue[700],
cursorColor: "OrangeRed", cursorColor: theme.colors.red[500],
hideScrollbar: true,
autoScroll: false,
autoCenter: false,
barWidth: 3,
barGap: 2,
cursorWidth: 2,
}, },
playerStyle: { playerStyle: {
cursor: "pointer", cursor: "pointer",
backgroundColor: "RGB(240 240 240)",
borderRadius: "15px",
}, },
marker: ` marker: `
border-left: solid 1px orange; border-left: solid 1px orange;
padding: 0 2px 0 5px; padding: 0 2px 0 5px;
font-size: 0.7rem; font-size: 0.7rem;
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
top: 0;
width: 100px; width: 100px;
max-width: fit-content; max-width: fit-content;
cursor: pointer; cursor: pointer;
@@ -25,5 +31,5 @@ export const waveSurferStyles = {
transition: width 100ms linear; transition: width 100ms linear;
z-index: 0; z-index: 0;
`, `,
markerHover: { backgroundColor: "orange" }, markerHover: { backgroundColor: theme.colors.gray[200] },
}; };

View File

@@ -29,33 +29,35 @@ const accordionTheme = defineMultiStyleConfig({
variants: { custom }, variants: { custom },
}); });
const theme = extendTheme({ export const colors = {
colors: { blue: {
blue: { primary: "#3158E2",
primary: "#3158E2", 500: "#3158E2",
500: "#3158E2", light: "#B1CBFF",
light: "#B1CBFF", 200: "#B1CBFF",
200: "#B1CBFF", dark: "#0E1B48",
dark: "#0E1B48", 900: "#0E1B48",
900: "#0E1B48",
},
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
},
light: "#FFFFFF",
dark: "#0C0D0E",
}, },
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
},
light: "#FFFFFF",
dark: "#0C0D0E",
};
const theme = extendTheme({
colors,
components: { components: {
Accordion: accordionTheme, Accordion: accordionTheme,
}, },