Merge pull request #231 from Monadical-SAS/redesign-reflector

Redesign reflector
This commit is contained in:
Sara
2023-09-21 18:16:25 +02:00
committed by GitHub
16 changed files with 430 additions and 383 deletions

View File

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

View File

@@ -18,16 +18,16 @@ const ErrorMessage: React.FC = () => {
if (!isVisible || !error) return null;
return (
<div
<button
onClick={() => {
setIsVisible(false);
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"
>
<span className="block sm:inline">{error?.message}</span>
</div>
</button>
);
};

View File

@@ -1,12 +1,14 @@
import "./styles/globals.scss";
import { Roboto } from "next/font/google";
import { Poppins } from "next/font/google";
import { Metadata } from "next";
import FiefWrapper from "./(auth)/fiefWrapper";
import UserInfo from "./(auth)/userInfo";
import { ErrorProvider } from "./(errors)/errorContext";
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 = {
title: {
@@ -55,24 +57,39 @@ export const metadata: Metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={roboto.className + " flex flex-col min-h-screen"}>
<body className={poppins.className + " h-screen"}>
<FiefWrapper>
<ErrorProvider>
<ErrorMessage />
<div id="container">
<div className="flex flex-col items-center h-[100svh] bg-gradient-to-r from-[#8ec5fc30] to-[#e0c3fc42]">
<div
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 />
</header>
<div className="h-[13svh] flex flex-col justify-center items-center">
<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>
{children}
</div>
</ErrorProvider>
</FiefWrapper>

View File

@@ -6,15 +6,10 @@ button {
background-color: transparent;
font-family: inherit;
padding: 0;
cursor: pointer;
/* Visual */
background-color: #3e68ff;
color: #fff;
border-radius: 8px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.18);
/* Size */
padding: 0.25em 0.75em;
min-width: 10ch;
padding: 0.4em 1em;
min-height: 44px;
/* Text */
text-align: center;
@@ -23,11 +18,20 @@ button {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: start;
/* Optional - see "Gotchas" */
/* Animation */
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 */
@@ -55,13 +59,6 @@ button[disabled]:hover {
cursor: not-allowed !important;
}
/* Custom button properties */
input[type="button"],
button {
width: 243px;
border: solid 1px #dadada;
}
/* Red button states */
input[type="button"][data-color="red"],
button[data-color="red"],

View File

@@ -10,20 +10,8 @@
body {
background: white;
font-family: "Roboto", sans-serif;
}
.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%;
scrollbar-color: rgb(96 165 250) transparent;
scrollbar-width: thin;
}
.Dropdown-placeholder {
@@ -33,8 +21,17 @@ body {
top: 47% !important;
}
@media (max-width: 768px) {
.audio-source-dropdown .Dropdown-control {
max-width: 200px;
}
.Dropdown-control.Dropdown-disabled {
background-color: lightgray;
}
::-webkit-scrollbar {
visibility: visible;
width: 5px;
opacity: 0.7;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: rgb(96 165 250);
}

View File

@@ -4,11 +4,12 @@ import getApi from "../../lib/getApi";
import useTranscript from "../useTranscript";
import useTopics from "../useTopics";
import useWaveform from "../useWaveform";
import { Dashboard } from "../dashboard";
import { TopicList } from "../topicList";
import Recorder from "../recorder";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import "../../styles/button.css";
import FinalSummary from "../finalSummary";
type TranscriptDetails = {
params: {
@@ -34,34 +35,34 @@ export default function TranscriptDetails(details: TranscriptDetails) {
return (
<>
<div className="w-full flex flex-col items-center h-[100svh]">
{transcript?.loading === true ||
waveform?.loading == true ||
topics?.loading == true ? (
<Modal
title="Loading"
text={"Loading transcript..." + transcript.loading}
{transcript?.loading === true ||
waveform?.loading == true ||
topics?.loading == true ? (
<Modal title="Loading" text={"Loading transcript..."} />
) : (
<>
<Recorder
topics={topics?.topics || []}
useActiveTopic={useActiveTopic}
waveform={waveform?.waveform}
isPastMeeting={true}
transcriptId={transcript?.response?.id}
/>
) : (
<>
<Recorder
<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
topics={topics?.topics || []}
useActiveTopic={useActiveTopic}
waveform={waveform?.waveform}
isPastMeeting={true}
transcriptId={transcript?.response?.id}
/>
<Dashboard
transcriptionText={""}
finalSummary={{ summary: transcript?.response?.longSummary }}
topics={topics?.topics || []}
disconnected={false}
useActiveTopic={useActiveTopic}
/>
</>
)}
</div>
<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>
</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) {
return (
<div className="mt-2 p-2 bg-white temp-transcription rounded">
<h2>Final Summary</h2>
<div className="overflow-y-auto h-auto max-h-full">
<h2 className="text-xl font-bold">Final Summary</h2>
<p>{props.text}</p>
</div>
);

View File

@@ -4,8 +4,11 @@ type LiveTranscriptionProps = {
export default function LiveTrancription(props: LiveTranscriptionProps) {
return (
<div className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
&nbsp;{props.text}&nbsp;
<div className="text-center p-4">
<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>
);
}

View File

@@ -6,7 +6,7 @@ type ModalProps = {
export default function Modal(props: ModalProps) {
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>
<p className="text-gray-500 text-center mt-5">{props.text}</p>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import Recorder from "../recorder";
import { Dashboard } from "../dashboard";
import { TopicList } from "../topicList";
import useWebRTC from "../useWebRTC";
import useTranscript from "../useTranscript";
import { useWebSockets } from "../useWebSockets";
@@ -9,6 +9,9 @@ import useAudioDevice from "../useAudioDevice";
import "../../styles/button.css";
import { Topic } from "../webSocketTypes";
import getApi from "../../lib/getApi";
import AudioInputsDropdown from "../audioInputsDropdown";
import LiveTrancription from "../liveTranscription";
import DisconnectedIndicator from "../disconnectedIndicator";
const TranscriptCreate = () => {
const [stream, setStream] = useState<MediaStream | null>(null);
@@ -39,7 +42,7 @@ const TranscriptCreate = () => {
} = useAudioDevice();
return (
<div className="w-full flex flex-col items-center h-[100svh]">
<>
{permissionOk ? (
<>
<Recorder
@@ -50,51 +53,76 @@ const TranscriptCreate = () => {
}}
topics={webSockets.topics}
getAudioStream={getAudioStream}
audioDevices={audioDevices}
useActiveTopic={useActiveTopic}
isPastMeeting={false}
audioDevices={audioDevices}
/>
<Dashboard
transcriptionText={webSockets.transcriptText}
finalSummary={webSockets.finalSummary}
topics={webSockets.topics}
disconnected={disconnected}
useActiveTopic={useActiveTopic}
/>
<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">
<TopicList
topics={webSockets.topics}
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">
<div className="py-2 h-auto">
<LiveTrancription text={webSockets.transcriptText} />
</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">
<h1 className="text-2xl font-bold text-blue-500">
Audio Permissions
</h1>
{loading ? (
<p className="text-gray-500 text-center mt-5">
Checking permission...
<div></div>
<section className="flex flex-col w-full h-full items-center justify-evenly p-4 md:px-6 md:py-8">
<div className="flex flex-col max-w-2xl items-center justify-center">
<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="text-gray-500 text-center mt-5">
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-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded m-auto"
onClick={requestPermission}
disabled={permissionDenied}
>
{permissionDenied ? "Access denied" : "Grant Permission"}
</button>
</>
)}
</div>
<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 ? (
<p className="text-gray-500 text-center">
Checking permission...
</p>
) : (
<>
<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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMicrophone } 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 { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../api";
const AudioInputsDropdown: React.FC<{
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}
/>
);
};
import AudioInputsDropdown from "./audioInputsDropdown";
import { Option } from "react-dropdown";
type RecorderProps = {
setStream?: React.Dispatch<React.SetStateAction<MediaStream | null>>;
onStop?: () => void;
topics: Topic[];
getAudioStream?: (deviceId: string | null) => Promise<MediaStream | null>;
getAudioStream?: (deviceId) => Promise<MediaStream | null>;
audioDevices?: Option[];
useActiveTopic: [
Topic | null,
@@ -66,17 +36,17 @@ export default function Recorder(props: RecorderProps) {
const [isRecording, setIsRecording] = useState<boolean>(false);
const [hasRecorded, setHasRecorded] = useState<boolean>(props.isPastMeeting);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState<number>(0);
const [timeInterval, setTimeInterval] = useState<number | null>(null);
const [duration, setDuration] = useState<number>(0);
const [waveRegions, setWaveRegions] = useState<CustomRegionsPlugin | null>(
null,
);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [recordStarted, setRecordStarted] = useState(false);
const [activeTopic, setActiveTopic] = props.useActiveTopic;
const topicsRef = useRef(props.topics);
const [showDevices, setShowDevices] = useState(false);
useEffect(() => {
if (waveformRef.current) {
@@ -88,7 +58,7 @@ export default function Recorder(props: RecorderProps) {
hideScrollbar: true,
autoCenter: true,
barWidth: 2,
height: 90,
height: "auto",
url: props.transcriptId
? `${process.env.NEXT_PUBLIC_API_URL}/v1/transcripts/${props.transcriptId}/audio/mp3`
: undefined,
@@ -221,8 +191,8 @@ export default function Recorder(props: RecorderProps) {
record.stopRecording();
setIsRecording(false);
setHasRecorded(true);
} else if (props.getAudioStream) {
const stream = await props.getAudioStream(deviceId);
} else {
const stream = await getCurrentStream();
if (props.setStream) props.setStream(stream);
waveRegions?.clearRegions();
@@ -230,8 +200,6 @@ export default function Recorder(props: RecorderProps) {
await record.startRecording(stream);
setIsRecording(true);
}
} else {
throw new Error("No getAudioStream function provided");
}
};
@@ -245,69 +213,104 @@ export default function Recorder(props: RecorderProps) {
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 (
<div className="relative flex flex-col items-center justify-center max-w-[75vw] w-full">
<div className="flex my-2 mx-auto audio-source-dropdown">
{!hasRecorded && (
<>
<AudioInputsDropdown
audioDevices={props.audioDevices}
setDeviceId={setDeviceId}
disabled={isRecording}
/>
&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 className="flex items-center w-full relative">
<div className="flex-grow items-end relative">
<div ref={waveformRef} className="flex-grow rounded-2xl h-20"></div>
<div className="absolute right-2 bottom-0">
{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>
);
}

View File

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

View File

@@ -0,0 +1,99 @@
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>>,
];
};
export function TopicList({ topics, useActiveTopic }: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => {
if (autoscrollEnabled) scrollToBottom();
console.log(topics);
}, [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 (
<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 ? (
<>
<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 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

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