mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39:06 +00:00
Merge pull request #231 from Monadical-SAS/redesign-reflector
Redesign reflector
This commit is contained in:
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -55,25 +57,40 @@ export const metadata: Metadata = {
|
|||||||
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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -10,20 +10,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background: white;
|
background: white;
|
||||||
|
scrollbar-color: rgb(96 165 250) 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,17 @@ 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 {
|
||||||
|
visibility: visible;
|
||||||
|
width: 5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgb(96 165 250);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 @@ 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}
|
||||||
/>
|
/>
|
||||||
</>
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
www/app/transcripts/audioInputsDropdown.tsx
Normal file
38
www/app/transcripts/audioInputsDropdown.tsx
Normal 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;
|
||||||
@@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
{props.text}
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,9 @@ 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 AudioInputsDropdown from "../audioInputsDropdown";
|
||||||
|
import LiveTrancription from "../liveTranscription";
|
||||||
|
import DisconnectedIndicator from "../disconnectedIndicator";
|
||||||
|
|
||||||
const TranscriptCreate = () => {
|
const TranscriptCreate = () => {
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
@@ -39,7 +42,7 @@ const TranscriptCreate = () => {
|
|||||||
} = useAudioDevice();
|
} = useAudioDevice();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center h-[100svh]">
|
<>
|
||||||
{permissionOk ? (
|
{permissionOk ? (
|
||||||
<>
|
<>
|
||||||
<Recorder
|
<Recorder
|
||||||
@@ -50,32 +53,55 @@ const TranscriptCreate = () => {
|
|||||||
}}
|
}}
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
|
<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">
|
<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 +109,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 +118,11 @@ const TranscriptCreate = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="w-20"
|
|
||||||
onClick={handleRecClick}
|
|
||||||
data-color={isRecording ? "red" : "blue"}
|
|
||||||
disabled={!deviceId}
|
|
||||||
>
|
|
||||||
{isRecording ? "Stop" : "Record"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 left-0 w-10 h-10 ${
|
||||||
props.visible ? "flex" : "hidden"
|
props.visible ? "flex" : "hidden"
|
||||||
} ${
|
} top-[49%] text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl 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;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⬇
|
<FontAwesomeIcon icon={faArrowDown} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
www/app/transcripts/topicList.tsx
Normal file
99
www/app/transcripts/topicList.tsx
Normal 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)}]
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,9 @@ 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))",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user