From 807b954340c93e593880d12f4a5d6d834bfca386 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Tue, 13 Jan 2026 17:29:38 -0500 Subject: [PATCH] transcription UI --- .../[transcriptId]/_components/TopicList.tsx | 178 ++++++++++-------- .../(app)/transcripts/[transcriptId]/page.tsx | 173 ++++++++++++----- 2 files changed, 227 insertions(+), 124 deletions(-) diff --git a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx index fdf3db41..371d65a7 100644 --- a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx @@ -1,11 +1,10 @@ import React, { useState, useEffect } from "react"; import ScrollToBottom from "../../scrollToBottom"; import { Topic } from "../../webSocketTypes"; -import useParticipants from "../../useParticipants"; -import { Box, Flex, Text, Accordion } from "@chakra-ui/react"; -import { TopicItem } from "./TopicItem"; +import { Box, Flex, Text } from "@chakra-ui/react"; +import { formatTime } from "../../../../lib/time"; +import { getTopicColor } from "../../../../lib/topicColors"; import { TranscriptStatus } from "../../../../lib/transcript"; - import { featureEnabled } from "../../../../lib/features"; type TopicListProps = { @@ -18,6 +17,7 @@ type TopicListProps = { transcriptId: string; status: TranscriptStatus | null; currentTranscriptText: any; + onTopicClick?: (topicId: string) => void; }; export function TopicList({ @@ -27,30 +27,13 @@ export function TopicList({ transcriptId, status, currentTranscriptText, + onTopicClick, }: TopicListProps) { const [activeTopic, setActiveTopic] = useActiveTopic; + const [hoveredTopicId, setHoveredTopicId] = useState(null); const [autoscrollEnabled, setAutoscrollEnabled] = useState(true); - const participants = useParticipants(transcriptId); - const scrollToTopic = () => { - const topicDiv = document.getElementById(`topic-${activeTopic?.id}`); - - setTimeout(() => { - topicDiv?.scrollIntoView({ - behavior: "smooth", - block: "start", - inline: "nearest", - }); - }, 200); - }; - - useEffect(() => { - if (activeTopic && autoscroll) scrollToTopic(); - }, [activeTopic, autoscroll]); - - // scroll top is not rounded, heights are, so exact match won't work. - // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled - const toggleScroll = (element) => { + const toggleScroll = (element: HTMLElement) => { const bottom = Math.abs( element.scrollHeight - element.clientHeight - element.scrollTop, @@ -61,14 +44,19 @@ export function TopicList({ setAutoscrollEnabled(true); } }; - const handleScroll = (e) => { - toggleScroll(e.target); + + const handleScroll = (e: React.UIEvent) => { + toggleScroll(e.target as HTMLElement); + }; + + const scrollToBottom = () => { + const topicsDiv = document.getElementById("topics-scroll-div"); + if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight; }; useEffect(() => { if (autoscroll) { - const topicsDiv = document.getElementById("scroll-div"); - + const topicsDiv = document.getElementById("topics-scroll-div"); topicsDiv && toggleScroll(topicsDiv); } }, [activeTopic, autoscroll]); @@ -77,37 +65,41 @@ export function TopicList({ if (autoscroll && autoscrollEnabled) scrollToBottom(); }, [topics.length, currentTranscriptText]); - const scrollToBottom = () => { - const topicsDiv = document.getElementById("scroll-div"); - - if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight; - }; - - const getSpeakerName = (speakerNumber: number) => { - if (!participants.response) return; - return ( - participants.response.find( - (participant) => participant.speaker == speakerNumber, - )?.name || `Speaker ${speakerNumber}` - ); - }; - - const requireLogin = featureEnabled("requireLogin"); - useEffect(() => { if (autoscroll) { setActiveTopic(topics[topics.length - 1]); } }, [topics, autoscroll]); + const handleTopicClick = (topic: Topic) => { + setActiveTopic(topic); + if (onTopicClick) { + onTopicClick(topic.id); + } + }; + + const handleTopicMouseEnter = (topic: Topic) => { + setHoveredTopicId(topic.id); + // If already active, toggle off when mousing over + if (activeTopic?.id === topic.id) { + setActiveTopic(null); + } else { + setActiveTopic(topic); + } + }; + + const handleTopicMouseLeave = () => { + setHoveredTopicId(null); + }; + + const requireLogin = featureEnabled("requireLogin"); + return ( {autoscroll && ( @@ -118,45 +110,71 @@ export function TopicList({ )} {topics.length > 0 && ( - { - const selectedTopicId = details.value[0]; - const selectedTopic = selectedTopicId - ? topics.find((t) => t.id === selectedTopicId) - : null; - setActiveTopic(selectedTopic || null); - }} - > - {topics.map((topic) => ( - - ))} - + + {topics.map((topic, index) => { + const color = getTopicColor(index); + const isActive = activeTopic?.id === topic.id; + const isHovered = hoveredTopicId === topic.id; + + return ( + handleTopicClick(topic)} + onMouseEnter={() => handleTopicMouseEnter(topic)} + onMouseLeave={handleTopicMouseLeave} + > + {/* Color indicator */} + + + {/* Topic title */} + + {topic.title} + + + {/* Timestamp */} + + {formatTime(topic.timestamp)} + + + ); + })} + )} {status == "recording" && ( - + {currentTranscriptText} )} {(status == "recording" || status == "idle") && currentTranscriptText.length == 0 && topics.length == 0 && ( - + Full discussion transcript will appear here after you start recording. @@ -167,7 +185,7 @@ export function TopicList({ )} {status == "processing" && ( - + We are processing the recording, please wait. {!requireLogin && ( @@ -177,12 +195,12 @@ export function TopicList({ )} {status == "ended" && topics.length == 0 && ( - + Recording has ended without topics being found. )} {status == "error" && ( - + There was an error processing your recording )} diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index ead2d259..3ca18095 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -3,7 +3,9 @@ import Modal from "../modal"; import useTopics from "../useTopics"; import useWaveform from "../useWaveform"; import useMp3 from "../useMp3"; +import useParticipants from "../useParticipants"; import { TopicList } from "./_components/TopicList"; +import { TranscriptWithGutter } from "./_components/TranscriptWithGutter"; import { Topic } from "../webSocketTypes"; import React, { useEffect, useState, use } from "react"; import FinalSummary from "./finalSummary"; @@ -45,14 +47,91 @@ export default function TranscriptDetails(details: TranscriptDetails) { const mp3 = useMp3(transcriptId, waiting); const topics = useTopics(transcriptId); + const participants = useParticipants(transcriptId); const waveform = useWaveform( transcriptId, waiting || mp3.audioDeleted === true, ); const useActiveTopic = useState(null); + const [activeTopic, setActiveTopic] = useActiveTopic; const [finalSummaryElement, setFinalSummaryElement] = useState(null); + // IntersectionObserver for active topic detection based on scroll position + useEffect(() => { + if (!topics.topics || topics.topics.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + // Find the most visible segment + let mostVisibleEntry: IntersectionObserverEntry | null = null; + let maxRatio = 0; + + entries.forEach((entry) => { + if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { + maxRatio = entry.intersectionRatio; + mostVisibleEntry = entry; + } + }); + + if (mostVisibleEntry) { + // Extract topicId from segment id (format: "segment-{topicId}-{idx}") + const segmentId = mostVisibleEntry.target.id; + const match = segmentId.match(/^segment-([^-]+)-/); + if (match) { + const topicId = match[1]; + const topic = topics.topics?.find((t) => t.id === topicId); + if (topic && activeTopic?.id !== topic.id) { + setActiveTopic(topic); + } + } + } + }, + { + threshold: [0, 0.25, 0.5, 0.75, 1], + rootMargin: "-20% 0px -20% 0px", + }, + ); + + // Observe all segment elements + const segments = document.querySelectorAll('[id^="segment-"]'); + segments.forEach((segment) => observer.observe(segment)); + + return () => observer.disconnect(); + }, [topics.topics, activeTopic?.id, setActiveTopic]); + + // Scroll handlers for bidirectional navigation + const handleTopicClick = (topicId: string) => { + // Scroll to first segment of this topic in transcript + const firstSegment = document.querySelector(`[id^="segment-${topicId}-"]`); + if (firstSegment) { + firstSegment.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }; + + const handleGutterClick = (topicId: string) => { + // Scroll to topic in list + const topicChip = document.getElementById(`topic-${topicId}`); + if (topicChip) { + topicChip.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }; + + const getSpeakerName = (speakerNumber: number) => { + if (!participants.response) return `Speaker ${speakerNumber}`; + return ( + participants.response.find( + (participant) => participant.speaker == speakerNumber, + )?.name || `Speaker ${speakerNumber}` + ); + }; + useEffect(() => { if (!waiting || !transcript.data) return; @@ -150,43 +229,39 @@ export default function TranscriptDetails(details: TranscriptDetails) { )} )} - - - - - { - transcript.refetch().then(() => {}); - }} - transcript={transcript.data || null} - topics={topics.topics} - finalSummaryElement={finalSummaryElement} - /> - - {mp3.audioDeleted && ( - - No audio is available because one or more participants didn't - consent to keep the audio - - )} + {/* Title */} + + + { + transcript.refetch().then(() => {}); + }} + transcript={transcript.data || null} + topics={topics.topics} + finalSummaryElement={finalSummaryElement} + /> - + {mp3.audioDeleted && ( + + No audio is available because one or more participants didn't + consent to keep the audio + + )} + + + {/* Topics List (top section - fixed height, scrollable) */} + + {/* Transcript with colored gutter (bottom section - normal document flow) */} + {topics.topics && topics.topics.length > 0 && ( + + )} + + {/* Final Summary (at bottom) */} {transcript.data && topics.topics ? ( - <> - { - transcript.refetch().then(() => {}); - }} - finalSummaryRef={setFinalSummaryElement} - /> - + { + transcript.refetch().then(() => {}); + }} + finalSummaryRef={setFinalSummaryElement} + /> ) : ( - -
+ + {transcript?.data?.status == "processing" ? ( Loading Transcript ) : ( @@ -217,10 +302,10 @@ export default function TranscriptDetails(details: TranscriptDetails) { back later )} -
+
)} - + );