1
1
import { useQuery , useQueryClient } from "@tanstack/react-query" ;
2
2
import { useMatch } from "@tanstack/react-router" ;
3
3
import { writeText } from "@tauri-apps/plugin-clipboard-manager" ;
4
- import { AudioLinesIcon , ClipboardIcon , Copy , UploadIcon } from "lucide-react" ;
4
+ import { AudioLinesIcon , ClipboardIcon , Copy , TextSearchIcon , UploadIcon } from "lucide-react" ;
5
5
import { useEffect , useRef , useState } from "react" ;
6
6
7
7
import { ParticipantsChipInner } from "@/components/editor-area/note-header/chips/participants-chip" ;
8
8
import { commands as dbCommands , Human , Word } from "@hypr/plugin-db" ;
9
9
import { commands as miscCommands } from "@hypr/plugin-misc" ;
10
10
import TranscriptEditor , { type SpeakerViewInnerProps , type TranscriptEditorRef } from "@hypr/tiptap/transcript" ;
11
11
import { Button } from "@hypr/ui/components/ui/button" ;
12
+ import { Input } from "@hypr/ui/components/ui/input" ;
12
13
import { Popover , PopoverContent , PopoverTrigger } from "@hypr/ui/components/ui/popover" ;
13
14
import { Spinner } from "@hypr/ui/components/ui/spinner" ;
14
15
import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from "@hypr/ui/components/ui/tooltip" ;
@@ -34,8 +35,9 @@ export function TranscriptView() {
34
35
useEffect ( ( ) => {
35
36
if ( words && words . length > 0 ) {
36
37
editorRef . current ?. setWords ( words ) ;
38
+ editorRef . current ?. scrollToBottom ( ) ;
37
39
}
38
- } , [ words ] ) ;
40
+ } , [ words , isLive ] ) ;
39
41
40
42
const handleCopyAll = ( ) => {
41
43
if ( words && words . length > 0 ) {
@@ -76,29 +78,35 @@ export function TranscriptView() {
76
78
77
79
return (
78
80
< div className = "w-full h-full flex flex-col" >
79
- < div className = "p -4 pb-0 " >
80
- < header className = "flex items-center gap-2 w-full" >
81
+ < div className = "px -4 py-1 border-b border-neutral-100 " >
82
+ < header className = "flex items-center justify-between w-full" >
81
83
{ ! showEmptyMessage && (
82
- < div className = "flex-1 text-md font-medium " >
83
- < div className = "flex text-md items-center gap-2" >
84
- Transcript
85
- { isLive
86
- && (
87
- < div className = "relative h-2 w-2" >
88
- < div className = "absolute inset-0 rounded-full bg-red-500/30 " > </ div >
89
- < div className = "absolute inset-0 rounded-full bg-red-500 animate-ping" > </ div >
90
- </ div >
91
- ) }
92
- </ div >
84
+ < div className = "flex items-center gap-2 " >
85
+ < h2 className = "text-sm font-semibold text-neutral-900" > Transcript </ h2 >
86
+ { isLive && (
87
+ < div className = "flex items-center gap-1.5" >
88
+ < div className = "relative h-1.5 w-1.5" >
89
+ < div className = "absolute inset-0 rounded-full bg-red-500/30" > </ div >
90
+ < div className = "absolute inset-0 rounded-full bg-red-500 animate-ping " > </ div >
91
+ </ div >
92
+ < span className = "text-xs font-medium text-red-600" > Live </ span >
93
+ </ div >
94
+ ) }
93
95
</ div >
94
96
) }
95
- < div className = "not-draggable flex items-center gap-2" >
97
+ < div className = "not-draggable flex items-center gap-1" >
98
+ { ( hasTranscript && sessionId ) && < SearchAndReplace editorRef = { editorRef } /> }
96
99
{ ( audioExist . data && ongoingSession . isInactive && hasTranscript && sessionId ) && (
97
100
< TooltipProvider key = "listen-recording-tooltip" >
98
101
< Tooltip >
99
102
< TooltipTrigger asChild >
100
- < Button variant = "ghost" size = "icon" className = "p-0" onClick = { handleOpenSession } >
101
- < AudioLinesIcon size = { 16 } className = "text-black" />
103
+ < Button
104
+ variant = "ghost"
105
+ size = "sm"
106
+ className = "h-7 w-7 p-0 hover:bg-neutral-100"
107
+ onClick = { handleOpenSession }
108
+ >
109
+ < AudioLinesIcon size = { 14 } className = "text-neutral-600" />
102
110
</ Button >
103
111
</ TooltipTrigger >
104
112
< TooltipContent side = "bottom" >
@@ -111,8 +119,13 @@ export function TranscriptView() {
111
119
< TooltipProvider key = "copy-all-tooltip" >
112
120
< Tooltip >
113
121
< TooltipTrigger asChild >
114
- < Button variant = "ghost" size = "icon" className = "p-0" onClick = { handleCopyAll } >
115
- < Copy size = { 16 } className = "text-black" />
122
+ < Button
123
+ variant = "ghost"
124
+ size = "sm"
125
+ className = "h-7 w-7 p-0 hover:bg-neutral-100"
126
+ onClick = { handleCopyAll }
127
+ >
128
+ < Copy size = { 14 } className = "text-neutral-600" />
116
129
</ Button >
117
130
</ TooltipTrigger >
118
131
< TooltipContent side = "bottom" >
@@ -195,12 +208,14 @@ function RenderEmpty({ sessionId }: { sessionId: string }) {
195
208
}
196
209
197
210
const SpeakerSelector = ( {
198
- onSpeakerIdChange ,
211
+ onSpeakerChange ,
199
212
speakerId,
200
213
speakerIndex,
201
214
} : SpeakerViewInnerProps ) => {
202
215
const [ isOpen , setIsOpen ] = useState ( false ) ;
216
+ const [ speakerRange , setSpeakerRange ] = useState < SpeakerChangeRange > ( "current" ) ;
203
217
const inactive = useOngoingSession ( s => s . status === "inactive" ) ;
218
+ const [ human , setHuman ] = useState < Human | null > ( null ) ;
204
219
205
220
const noteMatch = useMatch ( { from : "/app/note/$id" , shouldThrow : false } ) ;
206
221
const sessionId = noteMatch ?. params . id ;
@@ -211,24 +226,39 @@ const SpeakerSelector = ({
211
226
queryFn : ( ) => dbCommands . sessionListParticipants ( sessionId ! ) ,
212
227
} ) ;
213
228
229
+ useEffect ( ( ) => {
230
+ if ( human ) {
231
+ onSpeakerChange ( human ) ;
232
+ }
233
+ } , [ human ] ) ;
234
+
235
+ useEffect ( ( ) => {
236
+ if ( participants . length === 1 && participants [ 0 ] ) {
237
+ setHuman ( participants [ 0 ] ) ;
238
+ return ;
239
+ }
240
+
241
+ const foundHuman = participants . find ( ( s ) => s . id === speakerId ) ;
242
+ if ( foundHuman ) {
243
+ setHuman ( foundHuman ) ;
244
+ }
245
+ } , [ participants , speakerId ] ) ;
246
+
214
247
const handleClickHuman = ( human : Human ) => {
215
- onSpeakerIdChange ( human . id ) ;
248
+ setHuman ( human ) ;
216
249
setIsOpen ( false ) ;
217
250
} ;
218
251
219
- const foundSpeaker = participants . length === 1 ? participants [ 0 ] : participants . find ( ( s ) => s . id === speakerId ) ;
220
- const displayName = foundSpeaker ?. full_name ?? `Speaker ${ speakerIndex ?? 0 } ` ;
221
-
222
252
if ( ! sessionId ) {
223
253
return < p > </ p > ;
224
254
}
225
255
226
- if ( ! inactive && ! foundSpeaker ) {
256
+ if ( ! inactive && ! human ) {
227
257
return < p > </ p > ;
228
258
}
229
259
230
260
return (
231
- < div className = "mt-2" >
261
+ < div className = "mt-2 sticky top-0 z-10 bg-neutral-50 " >
232
262
< Popover open = { isOpen } onOpenChange = { setIsOpen } >
233
263
< PopoverTrigger
234
264
onMouseDown = { ( e ) => {
@@ -237,13 +267,136 @@ const SpeakerSelector = ({
237
267
} }
238
268
>
239
269
< span className = "underline py-1 font-semibold" >
240
- { displayName }
270
+ { human ? ( human . full_name ?? "You" ) : `Speaker ${ speakerIndex ?? 0 } ` }
241
271
</ span >
242
272
</ PopoverTrigger >
243
273
< PopoverContent align = "start" side = "bottom" >
244
- < ParticipantsChipInner sessionId = { sessionId } handleClickHuman = { handleClickHuman } />
274
+ < div className = "space-y-4" >
275
+ { ! speakerId && (
276
+ < div className = "border-b border-neutral-100 pb-3" >
277
+ < SpeakerRangeSelector
278
+ value = { speakerRange }
279
+ onChange = { setSpeakerRange }
280
+ />
281
+ </ div >
282
+ ) }
283
+
284
+ < ParticipantsChipInner sessionId = { sessionId } handleClickHuman = { handleClickHuman } />
285
+ </ div >
245
286
</ PopoverContent >
246
287
</ Popover >
247
288
</ div >
248
289
) ;
249
290
} ;
291
+
292
+ type SpeakerChangeRange = "current" | "all" | "fromHere" ;
293
+
294
+ interface SpeakerRangeSelectorProps {
295
+ value : SpeakerChangeRange ;
296
+ onChange : ( value : SpeakerChangeRange ) => void ;
297
+ }
298
+
299
+ function SpeakerRangeSelector ( { value, onChange } : SpeakerRangeSelectorProps ) {
300
+ const options = [
301
+ { value : "current" as const , label : "Just this" } ,
302
+ { value : "all" as const , label : "Replace all" } ,
303
+ { value : "fromHere" as const , label : "From here" } ,
304
+ ] ;
305
+
306
+ return (
307
+ < div className = "space-y-1.5" >
308
+ < p className = "text-sm font-medium text-neutral-700" > Apply speaker change to:</ p >
309
+ < div className = "flex rounded-md border border-neutral-200 p-0.5 bg-neutral-50" >
310
+ { options . map ( ( option ) => (
311
+ < label key = { option . value } className = "flex-1 cursor-pointer" >
312
+ < input
313
+ type = "radio"
314
+ name = "speaker-range"
315
+ value = { option . value }
316
+ className = "sr-only"
317
+ checked = { value === option . value }
318
+ onChange = { ( ) => onChange ( option . value ) }
319
+ />
320
+ < div
321
+ className = { `px-2 py-1 text-xs font-medium text-center rounded transition-colors ${
322
+ value === option . value
323
+ ? "bg-white text-neutral-900 shadow-sm"
324
+ : "text-neutral-600 hover:text-neutral-900 hover:bg-white/50"
325
+ } `}
326
+ >
327
+ { option . label }
328
+ </ div >
329
+ </ label >
330
+ ) ) }
331
+ </ div >
332
+ </ div >
333
+ ) ;
334
+ }
335
+
336
+ function SearchAndReplace ( { editorRef } : { editorRef : React . RefObject < any > } ) {
337
+ const [ expanded , setExpanded ] = useState ( false ) ;
338
+ const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
339
+ const [ replaceTerm , setReplaceTerm ] = useState ( "" ) ;
340
+
341
+ useEffect ( ( ) => {
342
+ if ( editorRef . current ) {
343
+ editorRef . current . editor . commands . setSearchTerm ( searchTerm ) ;
344
+
345
+ if ( searchTerm . substring ( 0 , searchTerm . length - 1 ) === replaceTerm ) {
346
+ setReplaceTerm ( searchTerm ) ;
347
+ }
348
+ }
349
+ } , [ searchTerm ] ) ;
350
+
351
+ useEffect ( ( ) => {
352
+ if ( editorRef . current ) {
353
+ editorRef . current . editor . commands . setReplaceTerm ( replaceTerm ) ;
354
+ }
355
+ } , [ replaceTerm ] ) ;
356
+
357
+ const handleReplaceAll = ( ) => {
358
+ if ( editorRef . current && searchTerm ) {
359
+ editorRef . current . editor . commands . replaceAll ( replaceTerm ) ;
360
+ setExpanded ( false ) ;
361
+ setSearchTerm ( "" ) ;
362
+ setReplaceTerm ( "" ) ;
363
+ }
364
+ } ;
365
+
366
+ return (
367
+ < Popover open = { expanded } onOpenChange = { setExpanded } >
368
+ < PopoverTrigger asChild >
369
+ < Button
370
+ className = "w-8"
371
+ variant = "ghost"
372
+ size = "icon"
373
+ >
374
+ < TextSearchIcon size = { 14 } className = "text-neutral-600" />
375
+ </ Button >
376
+ </ PopoverTrigger >
377
+ < PopoverContent className = "w-full p-2" align = "start" side = "left" >
378
+ < div className = "flex flex-row gap-2" >
379
+ < Input
380
+ className = "h-5 w-32"
381
+ value = { searchTerm }
382
+ onChange = { ( e ) => setSearchTerm ( e . target . value ) }
383
+ placeholder = "Search"
384
+ />
385
+ < Input
386
+ className = "h-5 w-32"
387
+ value = { replaceTerm }
388
+ onChange = { ( e ) => setReplaceTerm ( e . target . value ) }
389
+ placeholder = "Replace"
390
+ />
391
+ < Button
392
+ className = "h-5"
393
+ variant = "default"
394
+ onClick = { handleReplaceAll }
395
+ >
396
+ Replace
397
+ </ Button >
398
+ </ div >
399
+ </ PopoverContent >
400
+ </ Popover >
401
+ ) ;
402
+ }
0 commit comments