Merge branch 'main' of github.com:Monadical-SAS/reflector into enhancements

This commit is contained in:
Gokul Mohanarangan
2023-09-25 09:08:41 +05:30
18 changed files with 529 additions and 384 deletions

View File

@@ -4,40 +4,26 @@ import {
useFiefUserinfo, useFiefUserinfo,
} from "@fief/fief/nextjs/react"; } from "@fief/fief/nextjs/react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
export default function UserInfo() { export default function UserInfo() {
const isAuthenticated = useFiefIsAuthenticated(); const isAuthenticated = useFiefIsAuthenticated();
const userinfo = useFiefUserinfo(); const userinfo = useFiefUserinfo();
return ( return !isAuthenticated ? (
<header className="bg-black w-full border-b border-gray-700 flex justify-between items-center py-2 mb-3"> <span className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2">
{/* Logo on the left */} <Link href="/login" className="outline-none">
<Link href="/"> Log in or create account
<Image
src="/reach.png"
width={16}
height={16}
className="h-6 w-auto ml-2"
alt="Reflector"
/>
</Link> </Link>
</span>
{/* Text link on the right */} ) : (
{!isAuthenticated && ( <span className="font-light px-2">
<span className="text-white hover:underline font-thin px-2"> {userinfo?.email} (
<Link href="/login">Log in or create account</Link> <span className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px]">
</span> <Link href="/logout" className="outline-none">
)} Log out
{isAuthenticated && ( </Link>
<span className="text-white font-thin px-2"> </span>
{userinfo?.email} ( )
<span className="hover:underline"> </span>
<Link href="/logout">Log out</Link>
</span>
)
</span>
)}
</header>
); );
} }

View File

@@ -18,16 +18,16 @@ const ErrorMessage: React.FC = () => {
if (!isVisible || !error) return null; if (!isVisible || !error) return null;
return ( return (
<div <button
onClick={() => { onClick={() => {
setIsVisible(false); setIsVisible(false);
setError(null); setError(null);
}} }}
className="max-w-xs z-50 fixed top-16 right-10 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded transition-opacity duration-300 ease-out opacity-100 hover:opacity-75 cursor-pointer transform hover:scale-105" className="max-w-xs z-50 fixed bottom-5 right-5 md:bottom-10 md:right-10 border-solid bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded transition-opacity duration-300 ease-out opacity-100 hover:opacity-80 focus-visible:opacity-80 cursor-pointer transform hover:scale-105 focus-visible:scale-105"
role="alert" role="alert"
> >
<span className="block sm:inline">{error?.message}</span> <span className="block sm:inline">{error?.message}</span>
</div> </button>
); );
}; };

View File

@@ -1,12 +1,14 @@
import "./styles/globals.scss"; import "./styles/globals.scss";
import { Roboto } from "next/font/google"; import { Poppins } from "next/font/google";
import { Metadata } from "next"; import { Metadata } from "next";
import FiefWrapper from "./(auth)/fiefWrapper"; import FiefWrapper from "./(auth)/fiefWrapper";
import UserInfo from "./(auth)/userInfo"; import UserInfo from "./(auth)/userInfo";
import { ErrorProvider } from "./(errors)/errorContext"; import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage"; import ErrorMessage from "./(errors)/errorMessage";
import Image from "next/image";
import Link from "next/link";
const roboto = Roboto({ subsets: ["latin"], weight: "400" }); const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -50,29 +52,46 @@ export const metadata: Metadata = {
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
}, },
robots: { index: false, follow: false, noarchive: true, noimageindex: true },
}; };
export default function RootLayout({ children }) { export default function RootLayout({ children }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={roboto.className + " flex flex-col min-h-screen"}> <body className={poppins.className + " h-screen"}>
<FiefWrapper> <FiefWrapper>
<ErrorProvider> <ErrorProvider>
<ErrorMessage /> <ErrorMessage />
<div id="container"> <div
<div className="flex flex-col items-center h-[100svh] bg-gradient-to-r from-[#8ec5fc30] to-[#e0c3fc42]"> id="container"
className="items-center h-[100svh] p-2 md:p-4 grid grid-rows-layout gap-2 md:gap-4"
>
<header className="flex justify-between items-center w-full">
{/* Logo on the left */}
<Link
href="/"
className="flex outline-blue-300 md:outline-none focus-visible:underline underline-offset-2 decoration-[.5px] decoration-gray-500"
>
<Image
src="/reach.png"
width={16}
height={16}
className="h-10 w-auto"
alt="Reflector"
/>
<div className="hidden flex-col ml-2 md:block">
<h1 className="text-4xl font-bold">Reflector</h1>
<p className="text-gray-500">
Capture The Signal, Not The Noise
</p>
</div>
</Link>
{/* Text link on the right */}
<UserInfo /> <UserInfo />
</header>
<div className="h-[13svh] flex flex-col justify-center items-center"> {children}
<h1 className="text-5xl font-bold text-blue-500">
Reflector
</h1>
<p className="text-gray-500">
Capture The Signal, Not The Noise
</p>
</div>
{children}
</div>
</div> </div>
</ErrorProvider> </ErrorProvider>
</FiefWrapper> </FiefWrapper>

View File

@@ -1,4 +1,6 @@
"use client"
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Index() {
export default function Index() {
redirect("/transcripts/new"); redirect("/transcripts/new");
} }

View File

@@ -6,15 +6,10 @@ button {
background-color: transparent; background-color: transparent;
font-family: inherit; font-family: inherit;
padding: 0; padding: 0;
cursor: pointer;
/* Visual */ /* Visual */
background-color: #3e68ff;
color: #fff;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.18);
/* Size */ /* Size */
padding: 0.25em 0.75em; padding: 0.4em 1em;
min-width: 10ch;
min-height: 44px; min-height: 44px;
/* Text */ /* Text */
text-align: center; text-align: center;
@@ -23,11 +18,20 @@ button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: start;
/* Optional - see "Gotchas" */
/* Animation */ /* Animation */
transition: 220ms all ease-in-out; transition: 220ms all ease-in-out;
display: block; }
button:focus-visible {
outline-style: none;
}
@media (max-width: 768px) {
input[type="button"],
button {
padding: 0.25em 0.75em;
min-height: 30px;
}
} }
/* Button modifiers */ /* Button modifiers */
@@ -55,13 +59,6 @@ button[disabled]:hover {
cursor: not-allowed !important; cursor: not-allowed !important;
} }
/* Custom button properties */
input[type="button"],
button {
width: 243px;
border: solid 1px #dadada;
}
/* Red button states */ /* Red button states */
input[type="button"][data-color="red"], input[type="button"][data-color="red"],
button[data-color="red"], button[data-color="red"],

View File

@@ -10,20 +10,8 @@
body { body {
background: white; background: white;
scrollbar-color: rgb(132, 186, 251) transparent;
font-family: "Roboto", sans-serif; scrollbar-width: thin;
}
.temp-transcription {
background: rgb(151 190 255);
border-radius: 5px;
border: solid 1px #808080;
margin: 1em 0;
}
.temp-transcription h2 {
font-weight: bold;
font-size: 130%;
} }
.Dropdown-placeholder { .Dropdown-placeholder {
@@ -33,8 +21,16 @@ body {
top: 47% !important; top: 47% !important;
} }
@media (max-width: 768px) { .Dropdown-control.Dropdown-disabled {
.audio-source-dropdown .Dropdown-control { background-color: lightgray;
max-width: 200px; }
}
::-webkit-scrollbar {
width: 5px;
opacity: 0.7;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: rgb(132, 186, 251);
} }

View File

@@ -4,11 +4,12 @@ import getApi from "../../lib/getApi";
import useTranscript from "../useTranscript"; import useTranscript from "../useTranscript";
import useTopics from "../useTopics"; import useTopics from "../useTopics";
import useWaveform from "../useWaveform"; import useWaveform from "../useWaveform";
import { Dashboard } from "../dashboard"; import { TopicList } from "../topicList";
import Recorder from "../recorder"; import Recorder from "../recorder";
import { Topic } from "../webSocketTypes"; 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";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -34,34 +35,35 @@ export default function TranscriptDetails(details: TranscriptDetails) {
return ( return (
<> <>
<div className="w-full flex flex-col items-center h-[100svh]"> {transcript?.loading === true ||
{transcript?.loading === true || waveform?.loading == true ||
waveform?.loading == true || topics?.loading == true ? (
topics?.loading == true ? ( <Modal title="Loading" text={"Loading transcript..."} />
<Modal ) : (
title="Loading" <>
text={"Loading transcript..." + transcript.loading} <Recorder
topics={topics?.topics || []}
useActiveTopic={useActiveTopic}
waveform={waveform?.waveform}
isPastMeeting={true}
transcriptId={transcript?.response?.id}
/> />
) : ( <div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-2 lg:grid-rows-1 gap-2 lg:gap-4 h-full">
<> <TopicList
<Recorder
topics={topics?.topics || []} topics={topics?.topics || []}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform?.waveform} autoscroll={false}
isPastMeeting={true}
transcriptId={transcript?.response?.id}
/> />
<section className="relative w-full h-auto max-h-full bg-blue-400/20 rounded-lg md:rounded-xl px-2 md:px-4 flex flex-col justify-center align-center">
<Dashboard <div className="py-2 h-full">
transcriptionText={""} {transcript?.response?.longSummary && (
finalSummary={{ summary: transcript?.response?.longSummary }} <FinalSummary text={transcript?.response?.longSummary} />
topics={topics?.topics || []} )}
disconnected={false} </div>
useActiveTopic={useActiveTopic} </section>
/> </div>
</> </>
)} )}
</div>
</> </>
); );
} }

View File

@@ -0,0 +1,38 @@
import React, { useEffect, useState } from "react";
import Dropdown, { Option } from "react-dropdown";
import "react-dropdown/style.css";
const AudioInputsDropdown: React.FC<{
audioDevices: Option[];
disabled: boolean;
hide: () => void;
setDeviceId: React.Dispatch<React.SetStateAction<string | null>>;
}> = (props) => {
const [ddOptions, setDdOptions] = useState<Option[]>([]);
useEffect(() => {
if (props.audioDevices) {
setDdOptions(props.audioDevices);
props.setDeviceId(
props.audioDevices.length > 0 ? props.audioDevices[0].value : null,
);
}
}, [props.audioDevices]);
const handleDropdownChange = (option: Option) => {
props.setDeviceId(option.value);
props.hide();
};
return (
<Dropdown
options={ddOptions}
onChange={handleDropdownChange}
value={ddOptions[0]}
className="flex-grow w-full"
disabled={props.disabled}
/>
);
};
export default AudioInputsDropdown;

View File

@@ -1,122 +0,0 @@
import React, { useState, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faChevronDown,
} from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../lib/time";
import ScrollToBottom from "./scrollToBottom";
import DisconnectedIndicator from "./disconnectedIndicator";
import LiveTrancription from "./liveTranscription";
import FinalSummary from "./finalSummary";
import { Topic, FinalSummary as FinalSummaryType } from "./webSocketTypes";
type DashboardProps = {
transcriptionText: string;
finalSummary: FinalSummaryType;
topics: Topic[];
disconnected: boolean;
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
};
export function Dashboard({
transcriptionText,
finalSummary,
topics,
disconnected,
useActiveTopic,
}: DashboardProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => {
if (autoscrollEnabled) scrollToBottom();
}, [topics.length]);
const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div");
if (!topicsDiv)
console.error("Could not find topics div to scroll to bottom");
else topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const handleScroll = (e) => {
const bottom =
e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight;
if (!bottom && autoscrollEnabled) {
setAutoscrollEnabled(false);
} else if (bottom && !autoscrollEnabled) {
setAutoscrollEnabled(true);
}
};
return (
<>
<div className="relative h-[60svh] w-3/4 flex flex-col">
<div className="text-center pb-1 pt-4">
<h1 className="text-2xl font-bold text-blue-500">Meeting Notes</h1>
</div>
<ScrollToBottom
visible={!autoscrollEnabled}
hasFinalSummary={finalSummary ? true : false}
handleScrollBottom={scrollToBottom}
/>
<div
id="topics-div"
className="py-2 overflow-y-auto"
onScroll={handleScroll}
>
{topics.map((item, index) => (
<div key={index} className="border-b-2 py-2 hover:bg-[#8ec5fc30]">
<div
className="flex justify-between items-center cursor-pointer px-4"
onClick={() =>
setActiveTopic(activeTopic?.id == item.id ? null : item)
}
>
<div className="flex justify-between items-center">
<span className="font-light text-slate-500 pr-1">
[{formatTime(item.timestamp)}]
</span>{" "}
<span className="pr-1">{item.title}</span>
<FontAwesomeIcon
className={`transform transition-transform duration-200`}
icon={
activeTopic?.id == item.id
? faChevronDown
: faChevronRight
}
/>
</div>
</div>
{activeTopic?.id == item.id && (
<div className="p-2 mt-2 -mb-2 bg-slate-50 rounded">
{item.transcript}
</div>
)}
</div>
))}
{topics.length === 0 && (
<div className="text-center text-gray-500">
Discussion topics will appear here after you start recording. It
may take up to 5 minutes of conversation for the first topic to
appear.
</div>
)}
</div>
{finalSummary.summary && <FinalSummary text={finalSummary.summary} />}
</div>
{disconnected && <DisconnectedIndicator />}
<LiveTrancription text={transcriptionText} />
</>
);
}

View File

@@ -4,8 +4,8 @@ type FinalSummaryProps = {
export default function FinalSummary(props: FinalSummaryProps) { export default function FinalSummary(props: FinalSummaryProps) {
return ( return (
<div className="mt-2 p-2 bg-white temp-transcription rounded"> <div className="overflow-y-auto h-auto max-h-full">
<h2>Final Summary</h2> <h2 className="text-xl font-bold">Final Summary</h2>
<p>{props.text}</p> <p>{props.text}</p>
</div> </div>
); );

View File

@@ -4,8 +4,11 @@ type LiveTranscriptionProps = {
export default function LiveTrancription(props: LiveTranscriptionProps) { export default function LiveTrancription(props: LiveTranscriptionProps) {
return ( return (
<div className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl"> <div className="text-center p-4">
&nbsp;{props.text}&nbsp; <p className="text-lg md:text-xl font-bold line-clamp-4">
{/* Nous allons prendre quelques appels téléphoniques et répondre à quelques questions */}
{props.text}
</p>
</div> </div>
); );
} }

View File

@@ -6,7 +6,7 @@ type ModalProps = {
export default function Modal(props: ModalProps) { export default function Modal(props: ModalProps) {
return ( return (
<> <>
<div className="flex flex-col items-center justify-center w-fit bg-white px-6 py-8 mt-8 rounded-xl"> <div className="w-full flex flex-col items-center justify-center bg-white px-6 py-8 mt-8 rounded-xl">
<h1 className="text-2xl font-bold text-blue-500">{props.title}</h1> <h1 className="text-2xl font-bold text-blue-500">{props.title}</h1>
<p className="text-gray-500 text-center mt-5">{props.text}</p> <p className="text-gray-500 text-center mt-5">{props.text}</p>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Recorder from "../recorder"; import Recorder from "../recorder";
import { Dashboard } from "../dashboard"; import { TopicList } from "../topicList";
import useWebRTC from "../useWebRTC"; import useWebRTC from "../useWebRTC";
import useTranscript from "../useTranscript"; import useTranscript from "../useTranscript";
import { useWebSockets } from "../useWebSockets"; import { useWebSockets } from "../useWebSockets";
@@ -9,6 +9,10 @@ import useAudioDevice from "../useAudioDevice";
import "../../styles/button.css"; import "../../styles/button.css";
import { Topic } from "../webSocketTypes"; import { Topic } from "../webSocketTypes";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
import LiveTrancription from "../liveTranscription";
import DisconnectedIndicator from "../disconnectedIndicator";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear } from "@fortawesome/free-solid-svg-icons";
const TranscriptCreate = () => { const TranscriptCreate = () => {
const [stream, setStream] = useState<MediaStream | null>(null); const [stream, setStream] = useState<MediaStream | null>(null);
@@ -37,9 +41,10 @@ const TranscriptCreate = () => {
requestPermission, requestPermission,
getAudioStream, getAudioStream,
} = useAudioDevice(); } = useAudioDevice();
const [hasRecorded, setHasRecorded] = useState(false);
return ( return (
<div className="w-full flex flex-col items-center h-[100svh]"> <>
{permissionOk ? ( {permissionOk ? (
<> <>
<Recorder <Recorder
@@ -47,54 +52,97 @@ const TranscriptCreate = () => {
onStop={() => { onStop={() => {
webRTC?.peer?.send(JSON.stringify({ cmd: "STOP" })); webRTC?.peer?.send(JSON.stringify({ cmd: "STOP" }));
setStream(null); setStream(null);
setHasRecorded(true);
}} }}
topics={webSockets.topics} topics={webSockets.topics}
getAudioStream={getAudioStream} getAudioStream={getAudioStream}
audioDevices={audioDevices}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
isPastMeeting={false} isPastMeeting={false}
audioDevices={audioDevices}
/> />
<Dashboard <div className="grid grid-cols-1 lg:grid-cols-2 grid-rows-mobile-inner lg:grid-rows-1 gap-2 lg:gap-4 h-full">
transcriptionText={webSockets.transcriptText} <TopicList
finalSummary={webSockets.finalSummary} topics={webSockets.topics}
topics={webSockets.topics} useActiveTopic={useActiveTopic}
disconnected={disconnected} autoscroll={true}
useActiveTopic={useActiveTopic} />
/> <section className="w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl px-2 md:px-4 flex flex-col justify-center align-center">
{!hasRecorded ? (
<div className="py-2 h-auto">
<LiveTrancription text={webSockets.transcriptText} />
</div>
) : (
<div className="flex flex-col justify-center align center text-center">
<div className="p-2 md:p-4">
<FontAwesomeIcon
icon={faGear}
className="animate-spin-slow h-14 w-14 md:h-20 md:w-20"
/>
</div>
<p>
We are generating the final summary for you. This may take a
couple of minutes. Please do not navigate away from the page
during this time.
</p>
</div>
)}
</section>
</div>
{disconnected && <DisconnectedIndicator />}
</> </>
) : ( ) : (
<> <>
<div className="flex flex-col items-center justify-center w-fit bg-white px-6 py-8 mt-8 rounded-xl"> <div></div>
<h1 className="text-2xl font-bold text-blue-500"> <section className="flex flex-col w-full h-full items-center justify-evenly p-4 md:px-6 md:py-8">
Audio Permissions <div className="flex flex-col max-w-2xl items-center justify-center">
</h1> <h1 className="text-2xl font-bold mb-2">Reflector</h1>
{loading ? ( <p className="self-start">
<p className="text-gray-500 text-center mt-5"> Meet Monadical's own Reflector, your audio ally for hassle-free
Checking permission... insights.
</p> </p>
) : ( <p className="mb-4 md:text-justify">
<> With real-time transcriptions, translations, and summaries,
<p className="text-gray-500 text-center mt-5"> Reflector captures and categorizes the details of your meetings
Reflector needs access to your microphone to work. and events, all while keeping your data locked down tight on
<br /> your own infrastructure. Forget the scribbled notes, endless
{permissionDenied recordings, or third-party apps. Discover Reflector, a powerful
? "Please reset microphone permissions to continue." new way to elevate knowledge management and accessibility for
: "Please grant permission to continue."} all.
</p> </p>
<button </div>
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded m-auto" <div>
onClick={requestPermission} <div className="flex flex-col max-w-2xl items-center justify-center">
disabled={permissionDenied} <h2 className="text-2xl font-bold mb-2">Audio Permissions</h2>
> {loading ? (
{permissionDenied ? "Access denied" : "Grant Permission"} <p className="text-gray-500 text-center">
</button> Checking permission...
</> </p>
)} ) : (
</div> <>
<p className="text-gray-500 text-center">
Reflector needs access to your microphone to work.
<br />
{permissionDenied
? "Please reset microphone permissions to continue."
: "Please grant permission to continue."}
</p>
<button
className="mt-4 bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white font-bold py-2 px-4 rounded m-auto"
onClick={requestPermission}
disabled={permissionDenied}
>
{permissionDenied ? "Access denied" : "Grant Permission"}
</button>
</>
)}
</div>
</div>
</section>
</> </>
)} )}
</div> </>
); );
}; };

View File

@@ -5,50 +5,20 @@ import RecordPlugin from "../lib/custom-plugins/record";
import CustomRegionsPlugin from "../lib/custom-plugins/regions"; import CustomRegionsPlugin from "../lib/custom-plugins/regions";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMicrophone } from "@fortawesome/free-solid-svg-icons";
import { faDownload } from "@fortawesome/free-solid-svg-icons"; import { faDownload } from "@fortawesome/free-solid-svg-icons";
import Dropdown, { Option } from "react-dropdown";
import "react-dropdown/style.css";
import { formatTime } from "../lib/time"; import { formatTime } from "../lib/time";
import { Topic } from "./webSocketTypes"; import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../api"; import { AudioWaveform } from "../api";
import AudioInputsDropdown from "./audioInputsDropdown";
const AudioInputsDropdown: React.FC<{ import { Option } from "react-dropdown";
audioDevices?: Option[];
setDeviceId: React.Dispatch<React.SetStateAction<string | null>>;
disabled: boolean;
}> = (props) => {
const [ddOptions, setDdOptions] = useState<Option[]>([]);
useEffect(() => {
if (props.audioDevices) {
setDdOptions(props.audioDevices);
props.setDeviceId(
props.audioDevices.length > 0 ? props.audioDevices[0].value : null,
);
}
}, [props.audioDevices]);
const handleDropdownChange = (option: Option) => {
props.setDeviceId(option.value);
};
return (
<Dropdown
options={ddOptions}
onChange={handleDropdownChange}
value={ddOptions[0]}
disabled={props.disabled}
/>
);
};
type RecorderProps = { type RecorderProps = {
setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>; setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>;
onStop?: () => void; onStop?: () => void;
topics: Topic[]; topics: Topic[];
getAudioStream?: (deviceId: string | null) => Promise<MediaStream | null>; getAudioStream?: (deviceId) => Promise<MediaStream | null>;
audioDevices?: Option[]; audioDevices?: Option[];
useActiveTopic: [ useActiveTopic: [
Topic | null, Topic | null,
@@ -66,17 +36,17 @@ export default function Recorder(props: RecorderProps) {
const [isRecording, setIsRecording] = useState<boolean>(false); const [isRecording, setIsRecording] = useState<boolean>(false);
const [hasRecorded, setHasRecorded] = useState<boolean>(props.isPastMeeting); const [hasRecorded, setHasRecorded] = useState<boolean>(props.isPastMeeting);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [timeInterval, setTimeInterval] = useState<number | null>(null); const [timeInterval, setTimeInterval] = useState<number | null>(null);
const [duration, setDuration] = useState<number>(0); const [duration, setDuration] = useState<number>(0);
const [waveRegions, setWaveRegions] = useState<CustomRegionsPlugin | null>( const [waveRegions, setWaveRegions] = useState<CustomRegionsPlugin | null>(
null, null,
); );
const [deviceId, setDeviceId] = useState<string | null>(null);
const [recordStarted, setRecordStarted] = useState(false);
const [activeTopic, setActiveTopic] = props.useActiveTopic; const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics); const topicsRef = useRef(props.topics);
const [showDevices, setShowDevices] = useState(false);
useEffect(() => { useEffect(() => {
if (waveformRef.current) { if (waveformRef.current) {
@@ -88,7 +58,7 @@ export default function Recorder(props: RecorderProps) {
hideScrollbar: true, hideScrollbar: true,
autoCenter: true, autoCenter: true,
barWidth: 2, barWidth: 2,
height: 90, height: "auto",
url: props.transcriptId url: props.transcriptId
? `${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3` ? `${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`
: undefined, : undefined,
@@ -221,8 +191,8 @@ export default function Recorder(props: RecorderProps) {
record.stopRecording(); record.stopRecording();
setIsRecording(false); setIsRecording(false);
setHasRecorded(true); setHasRecorded(true);
} else if (props.getAudioStream) { } else {
const stream = await props.getAudioStream(deviceId); const stream = await getCurrentStream();
if (props.setStream) props.setStream(stream); if (props.setStream) props.setStream(stream);
waveRegions?.clearRegions(); waveRegions?.clearRegions();
@@ -230,8 +200,6 @@ export default function Recorder(props: RecorderProps) {
await record.startRecording(stream); await record.startRecording(stream);
setIsRecording(true); setIsRecording(true);
} }
} else {
throw new Error("No getAudioStream function provided");
} }
}; };
@@ -245,69 +213,104 @@ export default function Recorder(props: RecorderProps) {
return ""; return "";
}; };
const getCurrentStream = async () => {
setRecordStarted(true);
return deviceId && props.getAudioStream
? await props.getAudioStream(deviceId)
: null;
};
useEffect(() => {
if (props.audioDevices && props.audioDevices.length > 0) {
setDeviceId[props.audioDevices[0].value];
}
}, [props.audioDevices]);
return ( return (
<div className="relative flex flex-col items-center justify-center max-w-[75vw] w-full"> <div className="flex items-center w-full relative">
<div className="flex my-2 mx-auto audio-source-dropdown"> <div className="flex-grow items-end relative">
{!hasRecorded && ( <div ref={waveformRef} className="flex-grow rounded-2xl h-20"></div>
<> <div className="absolute right-2 bottom-0">
<AudioInputsDropdown {isRecording && (
audioDevices={props.audioDevices} <div className="inline-block bg-red-500 rounded-full w-2 h-2 my-auto mr-1 animate-ping"></div>
setDeviceId={setDeviceId} )}
disabled={isRecording} {timeLabel()}
/> </div>
&nbsp;
<button
className="w-20"
onClick={handleRecClick}
data-color={isRecording ? "red" : "blue"}
disabled={!deviceId}
>
{isRecording ? "Stop" : "Record"}
</button>
&nbsp;
</>
)}
{hasRecorded && (
<>
<button
className="w-20"
id="play-btn"
onClick={handlePlayClick}
data-color={isPlaying ? "orange" : "green"}
>
{isPlaying ? "Pause" : "Play"}
</button>
{props.transcriptId && (
<a
title="Download recording"
className="w-9 m-auto text-center cursor-pointer text-blue-300 hover:text-blue-700"
href={`${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`}
>
<FontAwesomeIcon icon={faDownload} />
</a>
)}
{!props.transcriptId && (
<a
id="download-recording"
title="Download recording"
className="invisible w-9 m-auto text-center cursor-pointer text-blue-300 hover:text-blue-700"
>
<FontAwesomeIcon icon={faDownload} />
</a>
)}
</>
)}
</div>
<div ref={waveformRef} className="w-full shadow-xl rounded-2xl"></div>
<div className="absolute bottom-0 right-2 text-xs text-black">
{isRecording && (
<div className="inline-block bg-red-500 rounded-full w-2 h-2 my-auto mr-1 animate-ping"></div>
)}
{timeLabel()}
</div> </div>
{hasRecorded && (
<>
<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"
onClick={handlePlayClick}
disabled={isRecording}
>
{isPlaying ? "Pause" : "Play"}
</button>
{props.transcriptId && (
<a
title="Download recording"
className="text-center cursor-pointer text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg outline-blue-400"
href={`${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`}
>
<FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
</a>
)}
{!props.transcriptId && (
<a
id="download-recording"
title="Download recording"
className="invisible text-center text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg outline-blue-400"
>
<FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
</a>
)}
</>
)}
{!hasRecorded && (
<>
<button
className={`${
isRecording
? "bg-red-400 hover:bg-red-500 focus-visible:bg-red-500"
: "bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500"
} text-white ml-2 md:ml:4 md:h-[78px] md:min-w-[100px] text-lg`}
onClick={handleRecClick}
disabled={isPlaying}
>
{isRecording ? "Stop" : "Record"}
</button>
{props.audioDevices && props.audioDevices?.length > 0 && (
<>
<button
className="text-center text-blue-400 hover:text-blue-700 ml-2 md:ml:4 p-2 rounded-lg focus-visible:outline outline-blue-400"
onClick={() => setShowDevices((prev) => !prev)}
>
<FontAwesomeIcon icon={faMicrophone} className="h-5 w-auto" />
</button>
<div
className={`absolute z-20 bottom-[-1rem] right-0 bg-white rounded ${
showDevices ? "visible" : "invisible"
}`}
>
<AudioInputsDropdown
setDeviceId={setDeviceId}
audioDevices={props.audioDevices}
disabled={recordStarted}
hide={() => setShowDevices(false)}
/>
</div>
</>
)}
</>
)}
</div> </div>
); );
} }

View File

@@ -1,23 +1,23 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown } from "@fortawesome/free-solid-svg-icons";
type ScrollToBottomProps = { type ScrollToBottomProps = {
visible: boolean; visible: boolean;
hasFinalSummary: boolean;
handleScrollBottom: () => void; handleScrollBottom: () => void;
}; };
export default function ScrollToBottom(props: ScrollToBottomProps) { export default function ScrollToBottom(props: ScrollToBottomProps) {
return ( return (
<div <div
className={`absolute right-5 w-10 h-10 ${ className={`absolute bottom-0 right-[0.15rem] md:right-[0.65rem] ${
props.visible ? "flex" : "hidden" props.visible ? "flex" : "hidden"
} ${ } text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 text-blue-400`}
props.hasFinalSummary ? "top-[49%]" : "bottom-1"
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
onClick={() => { onClick={() => {
props.handleScrollBottom(); props.handleScrollBottom();
return false; return false;
}} }}
> >
&#11015; <FontAwesomeIcon icon={faArrowDown} className="animate-bounce" />
</div> </div>
); );
} }

View File

@@ -0,0 +1,118 @@
import React, { useState, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faChevronDown,
} from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
type TopicListProps = {
topics: Topic[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
autoscroll: boolean;
};
export function TopicList({
topics,
useActiveTopic,
autoscroll,
}: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => {
if (autoscroll && autoscrollEnabled) scrollToBottom();
}, [topics.length]);
const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div");
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
// 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
const toggleScroll = (element) => {
const bottom =
Math.abs(
element.scrollHeight - element.clientHeight - element.scrollTop,
) < 2 || element.scrollHeight == element.clientHeight;
if (!bottom && autoscrollEnabled) {
setAutoscrollEnabled(false);
} else if (bottom && !autoscrollEnabled) {
setAutoscrollEnabled(true);
}
};
const handleScroll = (e) => {
toggleScroll(e.target);
};
useEffect(() => {
if (autoscroll) {
const topicsDiv = document.getElementById("topics-div");
topicsDiv && toggleScroll(topicsDiv);
}
}, [activeTopic, autoscroll]);
return (
<section className="relative w-full h-full bg-blue-400/20 rounded-lg md:rounded-xl px-2 md:px-4 flex flex-col justify-center align-center">
{topics.length > 0 ? (
<>
{autoscroll && (
<ScrollToBottom
visible={!autoscrollEnabled}
handleScrollBottom={scrollToBottom}
/>
)}
<div
id="topics-div"
className="overflow-y-auto py-2 h-full"
onScroll={handleScroll}
>
{topics.map((topic, index) => (
<button
key={index}
className="rounded-none border-solid border-0 border-b-blue-300 border-b last:border-none last:rounded-b-lg p-2 hover:bg-blue-400/20 focus-visible:bg-blue-400/20 text-left block w-full"
onClick={() =>
setActiveTopic(activeTopic?.id == topic.id ? null : topic)
}
>
<div className="w-full flex justify-between items-center rounded-lg md:rounded-xl text-lg md:text-xl font-bold leading-tight">
<p>
<span className="font-light font-mono text-slate-500 text-base md:text-lg">
[{formatTime(topic.timestamp)}]&nbsp;
</span>
<span>{topic.title}</span>
</p>
<FontAwesomeIcon
className="transform transition-transform duration-200 ml-2"
icon={
activeTopic?.id == topic.id
? faChevronDown
: faChevronRight
}
/>
</div>
{activeTopic?.id == topic.id && (
<div className="p-2">{topic.transcript}</div>
)}
</button>
))}
</div>
</>
) : (
<div className="text-center text-gray-500 p-4">
Discussion topics will appear here after you start recording. It may
take up to 5 minutes of conversation for the first topic to appear.
</div>
)}
</section>
);
}

View File

@@ -65,6 +65,58 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
}, },
]); ]);
setFinalSummary({ summary: "This is the final summary" });
}
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
setTranscriptText(
"This text is in English, and it is a pretty long sentence to test the limits",
);
setTopics([
{
id: "1",
timestamp: 10,
summary: "This is test topic 1",
title:
"Topic 1: Introduction to Quantum Mechanics, a brief overview of quantum mechanics and its principles.",
transcript:
"A brief overview of quantum mechanics and its principles.",
},
{
id: "2",
timestamp: 20,
summary: "This is test topic 2",
title:
"Topic 2: Machine Learning Algorithms, understanding the different types of machine learning algorithms.",
transcript:
"Understanding the different types of machine learning algorithms.",
},
{
id: "3",
timestamp: 30,
summary: "This is test topic 3",
title:
"Topic 3: Mental Health Awareness, ways to improve mental health and reduce stigma.",
transcript: "Ways to improve mental health and reduce stigma.",
},
{
id: "4",
timestamp: 40,
summary: "This is test topic 4",
title:
"Topic 4: Basics of Productivity, tips and tricks to increase daily productivity.",
transcript: "Tips and tricks to increase daily productivity.",
},
{
id: "5",
timestamp: 50,
summary: "This is test topic 5",
title:
"Topic 5: Future of Aviation, exploring the advancements and possibilities in aviation.",
transcript:
"Exploring the advancements and possibilities in aviation.",
},
]);
setFinalSummary({ summary: "This is the final summary" }); setFinalSummary({ summary: "This is the final summary" });
} }
}; };

View File

@@ -1,4 +1,5 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}", "./pages/**/*.{js,ts,jsx,tsx,mdx}",
@@ -7,10 +8,12 @@ module.exports = {
], ],
theme: { theme: {
extend: { extend: {
backgroundImage: { gridTemplateRows: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))", layout: "auto auto minmax(0, 1fr)",
"gradient-conic": "mobile-inner": "minmax(0, 2fr) minmax(0, 1fr)",
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", },
animation: {
"spin-slow": "spin 3s linear infinite",
}, },
}, },
}, },