diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index d0a8ad02..0f01a85e 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -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 ( -
- {/* Logo on the left */} - - Reflector + return !isAuthenticated ? ( + + + Log in or create account - - {/* Text link on the right */} - {!isAuthenticated && ( - - Log in or create account - - )} - {isAuthenticated && ( - - {userinfo?.email} ( - - Log out - - ) - - )} -
+ + ) : ( + + {userinfo?.email} ( + + + Log out + + + ) + ); } diff --git a/www/app/(errors)/errorMessage.tsx b/www/app/(errors)/errorMessage.tsx index d5109733..8b410c4c 100644 --- a/www/app/(errors)/errorMessage.tsx +++ b/www/app/(errors)/errorMessage.tsx @@ -18,16 +18,16 @@ const ErrorMessage: React.FC = () => { if (!isVisible || !error) return null; return ( -
{ 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" > {error?.message} -
+ ); }; diff --git a/www/app/layout.tsx b/www/app/layout.tsx index c15974a2..9742f1d4 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -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 ( - + -
-
+
+
+ {/* Logo on the left */} + + Reflector +
+

Reflector

+

+ Capture The Signal, Not The Noise +

+
+ + {/* Text link on the right */} +
-
-

- Reflector -

-

- Capture The Signal, Not The Noise -

-
- {children} -
+ {children}
diff --git a/www/app/styles/button.css b/www/app/styles/button.css index 2f643722..e8c41c47 100644 --- a/www/app/styles/button.css +++ b/www/app/styles/button.css @@ -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"], diff --git a/www/app/styles/globals.scss b/www/app/styles/globals.scss index 3845af58..0acacc90 100644 --- a/www/app/styles/globals.scss +++ b/www/app/styles/globals.scss @@ -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); } diff --git a/www/app/transcripts/[transcriptId]/page.tsx b/www/app/transcripts/[transcriptId]/page.tsx index 43e560e1..967eab12 100644 --- a/www/app/transcripts/[transcriptId]/page.tsx +++ b/www/app/transcripts/[transcriptId]/page.tsx @@ -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 ( <> -
- {transcript?.loading === true || - waveform?.loading == true || - topics?.loading == true ? ( - + ) : ( + <> + - ) : ( - <> - + - - - - )} -
+
+
+ {transcript?.response?.longSummary && ( + + )} +
+
+
+ + )} ); } diff --git a/www/app/transcripts/audioInputsDropdown.tsx b/www/app/transcripts/audioInputsDropdown.tsx new file mode 100644 index 00000000..7772358b --- /dev/null +++ b/www/app/transcripts/audioInputsDropdown.tsx @@ -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>; +}> = (props) => { + const [ddOptions, setDdOptions] = useState([]); + + 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 ( + + ); +}; + +export default AudioInputsDropdown; diff --git a/www/app/transcripts/dashboard.tsx b/www/app/transcripts/dashboard.tsx deleted file mode 100644 index 4f2fdf64..00000000 --- a/www/app/transcripts/dashboard.tsx +++ /dev/null @@ -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>, - ]; -}; - -export function Dashboard({ - transcriptionText, - finalSummary, - topics, - disconnected, - useActiveTopic, -}: DashboardProps) { - const [activeTopic, setActiveTopic] = useActiveTopic; - const [autoscrollEnabled, setAutoscrollEnabled] = useState(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 ( - <> -
-
-

Meeting Notes

-
- - - -
- {topics.map((item, index) => ( -
-
- setActiveTopic(activeTopic?.id == item.id ? null : item) - } - > -
- - [{formatTime(item.timestamp)}] - {" "} - {item.title} - -
-
- {activeTopic?.id == item.id && ( -
- {item.transcript} -
- )} -
- ))} - {topics.length === 0 && ( -
- Discussion topics will appear here after you start recording. It - may take up to 5 minutes of conversation for the first topic to - appear. -
- )} -
- - {finalSummary.summary && } -
- - {disconnected && } - - - - ); -} diff --git a/www/app/transcripts/finalSummary.tsx b/www/app/transcripts/finalSummary.tsx index d93fb6d4..a9646cee 100644 --- a/www/app/transcripts/finalSummary.tsx +++ b/www/app/transcripts/finalSummary.tsx @@ -4,8 +4,8 @@ type FinalSummaryProps = { export default function FinalSummary(props: FinalSummaryProps) { return ( -
-

Final Summary

+
+

Final Summary

{props.text}

); diff --git a/www/app/transcripts/liveTranscription.tsx b/www/app/transcripts/liveTranscription.tsx index 6241cdb2..2c913a91 100644 --- a/www/app/transcripts/liveTranscription.tsx +++ b/www/app/transcripts/liveTranscription.tsx @@ -4,8 +4,11 @@ type LiveTranscriptionProps = { export default function LiveTrancription(props: LiveTranscriptionProps) { return ( -
-  {props.text}  +
+

+ {/* Nous allons prendre quelques appels téléphoniques et répondre à quelques questions */} + {props.text} +

); } diff --git a/www/app/transcripts/modal.tsx b/www/app/transcripts/modal.tsx index b3cb0446..55e74fbd 100644 --- a/www/app/transcripts/modal.tsx +++ b/www/app/transcripts/modal.tsx @@ -6,7 +6,7 @@ type ModalProps = { export default function Modal(props: ModalProps) { return ( <> -
+

{props.title}

{props.text}

diff --git a/www/app/transcripts/new/page.tsx b/www/app/transcripts/new/page.tsx index f993f93e..10bfe789 100644 --- a/www/app/transcripts/new/page.tsx +++ b/www/app/transcripts/new/page.tsx @@ -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(null); @@ -39,7 +42,7 @@ const TranscriptCreate = () => { } = useAudioDevice(); return ( -
+ <> {permissionOk ? ( <> { }} topics={webSockets.topics} getAudioStream={getAudioStream} - audioDevices={audioDevices} useActiveTopic={useActiveTopic} isPastMeeting={false} + audioDevices={audioDevices} /> - +
+ +
+
+ +
+
+
+ + {disconnected && } ) : ( <> -
-

- Audio Permissions -

- {loading ? ( -

- Checking permission... +

+
+
+

Reflector

+

+ Meet Monadical's own Reflector, your audio ally for hassle-free + insights.

- ) : ( - <> -

- Reflector needs access to your microphone to work. -
- {permissionDenied - ? "Please reset microphone permissions to continue." - : "Please grant permission to continue."} -

- - - )} -
+

+ 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. +

+
+
+
+

Audio Permissions

+ {loading ? ( +

+ Checking permission... +

+ ) : ( + <> +

+ Reflector needs access to your microphone to work. +
+ {permissionDenied + ? "Please reset microphone permissions to continue." + : "Please grant permission to continue."} +

+ + + )} +
+
+ )} -
+ ); }; diff --git a/www/app/transcripts/recorder.tsx b/www/app/transcripts/recorder.tsx index 6c24109e..c91aca49 100644 --- a/www/app/transcripts/recorder.tsx +++ b/www/app/transcripts/recorder.tsx @@ -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>; - disabled: boolean; -}> = (props) => { - const [ddOptions, setDdOptions] = useState([]); - - 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 ( - - ); -}; +import AudioInputsDropdown from "./audioInputsDropdown"; +import { Option } from "react-dropdown"; type RecorderProps = { setStream?: React.Dispatch>; onStop?: () => void; topics: Topic[]; - getAudioStream?: (deviceId: string | null) => Promise; + getAudioStream?: (deviceId) => Promise; audioDevices?: Option[]; useActiveTopic: [ Topic | null, @@ -66,17 +36,17 @@ export default function Recorder(props: RecorderProps) { const [isRecording, setIsRecording] = useState(false); const [hasRecorded, setHasRecorded] = useState(props.isPastMeeting); const [isPlaying, setIsPlaying] = useState(false); - const [deviceId, setDeviceId] = useState(null); const [currentTime, setCurrentTime] = useState(0); const [timeInterval, setTimeInterval] = useState(null); const [duration, setDuration] = useState(0); const [waveRegions, setWaveRegions] = useState( null, ); - + const [deviceId, setDeviceId] = useState(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 ( -
-
- {!hasRecorded && ( - <> - -   - -   - - )} - - {hasRecorded && ( - <> - - - {props.transcriptId && ( - - - - )} - - {!props.transcriptId && ( - - - - )} - - )} -
-
-
- {isRecording && ( -
- )} - {timeLabel()} +
+
+
+
+ {isRecording && ( +
+ )} + {timeLabel()} +
+ + {hasRecorded && ( + <> + + + {props.transcriptId && ( + + + + )} + + {!props.transcriptId && ( + + + + )} + + )} + {!hasRecorded && ( + <> + + {props.audioDevices && props.audioDevices?.length > 0 && ( + <> + +
+ setShowDevices(false)} + /> +
+ + )} + + )}
); } diff --git a/www/app/transcripts/scrollToBottom.tsx b/www/app/transcripts/scrollToBottom.tsx index 9c267a66..a702ede9 100644 --- a/www/app/transcripts/scrollToBottom.tsx +++ b/www/app/transcripts/scrollToBottom.tsx @@ -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 (
{ props.handleScrollBottom(); return false; }} > - ⬇ +
); } diff --git a/www/app/transcripts/topicList.tsx b/www/app/transcripts/topicList.tsx new file mode 100644 index 00000000..02bba6f7 --- /dev/null +++ b/www/app/transcripts/topicList.tsx @@ -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>, + ]; +}; + +export function TopicList({ topics, useActiveTopic }: TopicListProps) { + const [activeTopic, setActiveTopic] = useActiveTopic; + const [autoscrollEnabled, setAutoscrollEnabled] = useState(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 ( +
+ {topics.length > 0 ? ( + <> + + +
+ {topics.map((topic, index) => ( + + ))} +
+ + ) : ( +
+ Discussion topics will appear here after you start recording. It may + take up to 5 minutes of conversation for the first topic to appear. +
+ )} +
+ ); +} diff --git a/www/tailwind.config.js b/www/tailwind.config.js index 78ebc4e7..7536965d 100644 --- a/www/tailwind.config.js +++ b/www/tailwind.config.js @@ -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)", }, }, },