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>
{/* Text link on the right */}
{!isAuthenticated && (
<span className="text-white hover:underline font-thin px-2">
<Link href="/login">Log in or create account</Link>
</span> </span>
)} ) : (
{isAuthenticated && ( <span className="font-light px-2">
<span className="text-white font-thin px-2">
{userinfo?.email} ( {userinfo?.email} (
<span className="hover:underline"> <span className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px]">
<Link href="/logout">Log out</Link> <Link href="/logout" className="outline-none">
Log out
</Link>
</span> </span>
) )
</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,30 +52,47 @@ 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"
<UserInfo /> className="items-center h-[100svh] p-2 md:p-4 grid grid-rows-layout gap-2 md:gap-4"
>
<div className="h-[13svh] flex flex-col justify-center items-center"> <header className="flex justify-between items-center w-full">
<h1 className="text-5xl font-bold text-blue-500"> {/* Logo on the left */}
Reflector <Link
</h1> 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"> <p className="text-gray-500">
Capture The Signal, Not The Noise Capture The Signal, Not The Noise
</p> </p>
</div> </div>
</Link>
{/* Text link on the right */}
<UserInfo />
</header>
{children} {children}
</div> </div>
</div>
</ErrorProvider> </ErrorProvider>
</FiefWrapper> </FiefWrapper>
</body> </body>

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,14 +35,10 @@ 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 <Modal title="Loading" text={"Loading transcript..."} />
title="Loading"
text={"Loading transcript..." + transcript.loading}
/>
) : ( ) : (
<> <>
<Recorder <Recorder
@@ -51,17 +48,22 @@ export default function TranscriptDetails(details: TranscriptDetails) {
isPastMeeting={true} isPastMeeting={true}
transcriptId={transcript?.response?.id} 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">
<Dashboard <TopicList
transcriptionText={""}
finalSummary={{ summary: transcript?.response?.longSummary }}
topics={topics?.topics || []} topics={topics?.topics || []}
disconnected={false}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={false}
/> />
</> <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">
<div className="py-2 h-full">
{transcript?.response?.longSummary && (
<FinalSummary text={transcript?.response?.longSummary} />
)} )}
</div> </div>
</section>
</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,35 +52,76 @@ 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}
disconnected={disconnected}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={true}
/> />
<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>
<p className="self-start">
Meet Monadical's own Reflector, your audio ally for hassle-free
insights.
</p>
<p className="mb-4 md:text-justify">
With real-time transcriptions, translations, and summaries,
Reflector captures and categorizes the details of your meetings
and events, all while keeping your data locked down tight on
your own infrastructure. Forget the scribbled notes, endless
recordings, or third-party apps. Discover Reflector, a powerful
new way to elevate knowledge management and accessibility for
all.
</p>
</div>
<div>
<div className="flex flex-col max-w-2xl items-center justify-center">
<h2 className="text-2xl font-bold mb-2">Audio Permissions</h2>
{loading ? ( {loading ? (
<p className="text-gray-500 text-center mt-5"> <p className="text-gray-500 text-center">
Checking permission... Checking permission...
</p> </p>
) : ( ) : (
<> <>
<p className="text-gray-500 text-center mt-5"> <p className="text-gray-500 text-center">
Reflector needs access to your microphone to work. Reflector needs access to your microphone to work.
<br /> <br />
{permissionDenied {permissionDenied
@@ -83,7 +129,7 @@ const TranscriptCreate = () => {
: "Please grant permission to continue."} : "Please grant permission to continue."}
</p> </p>
<button <button
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded m-auto" 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} onClick={requestPermission}
disabled={permissionDenied} disabled={permissionDenied}
> >
@@ -92,9 +138,11 @@ const TranscriptCreate = () => {
</> </>
)} )}
</div> </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,36 +213,42 @@ 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}
/>
&nbsp;
<button
className="w-20"
onClick={handleRecClick}
data-color={isRecording ? "red" : "blue"}
disabled={!deviceId}
>
{isRecording ? "Stop" : "Record"}
</button>
&nbsp;
</>
)} )}
{timeLabel()}
</div>
</div>
{hasRecorded && ( {hasRecorded && (
<> <>
<button <button
className="w-20" 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}
data-color={isPlaying ? "orange" : "green"} disabled={isRecording}
> >
{isPlaying ? "Pause" : "Play"} {isPlaying ? "Pause" : "Play"}
</button> </button>
@@ -282,10 +256,10 @@ export default function Recorder(props: RecorderProps) {
{props.transcriptId && ( {props.transcriptId && (
<a <a
title="Download recording" title="Download recording"
className="w-9 m-auto text-center cursor-pointer text-blue-300 hover:text-blue-700" 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`} href={`${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`}
> >
<FontAwesomeIcon icon={faDownload} /> <FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
</a> </a>
)} )}
@@ -293,21 +267,50 @@ export default function Recorder(props: RecorderProps) {
<a <a
id="download-recording" id="download-recording"
title="Download recording" title="Download recording"
className="invisible w-9 m-auto text-center cursor-pointer text-blue-300 hover:text-blue-700" 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} /> <FontAwesomeIcon icon={faDownload} className="h-5 w-auto" />
</a> </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 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> </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",
}, },
}, },
}, },