1
- import { createFileRoute } from "@tanstack/react-router" ;
1
+ import { useQuery } from "@tanstack/react-query" ;
2
+ import { createFileRoute , useParams } from "@tanstack/react-router" ;
2
3
import { ReplaceAllIcon } from "lucide-react" ;
4
+ import { PencilIcon } from "lucide-react" ;
3
5
import { useEffect , useRef , useState } from "react" ;
4
6
5
- import { commands as dbCommands } from "@hypr/plugin-db" ;
7
+ import { commands as dbCommands , type SpeakerIdentity , type Word } from "@hypr/plugin-db" ;
8
+ import { commands as windowsCommands } from "@hypr/plugin-windows" ;
6
9
import TranscriptEditor from "@hypr/tiptap/transcript" ;
7
10
import { Button } from "@hypr/ui/components/ui/button" ;
8
11
import { Input } from "@hypr/ui/components/ui/input" ;
@@ -12,17 +15,22 @@ export const Route = createFileRoute("/app/transcript/$id")({
12
15
component : Component ,
13
16
loader : async ( { params : { id } , context : { onboardingSessionId } } ) => {
14
17
const participants = await dbCommands . sessionListParticipants ( id ) ;
15
- const words = onboardingSessionId
18
+ const words = id === onboardingSessionId
16
19
? await dbCommands . getWordsOnboarding ( )
17
20
: await dbCommands . getWords ( id ) ;
18
21
19
22
return { participants, words } ;
20
23
} ,
21
24
} ) ;
22
25
26
+ type EditorContent = {
27
+ type : "doc" ;
28
+ content : SpeakerContent [ ] ;
29
+ } ;
30
+
23
31
type SpeakerContent = {
24
32
type : "speaker" ;
25
- attrs : { label : string } ;
33
+ attrs : { "speaker-index" : number | null ; "speaker-id" : string | null ; "speaker- label" : string | null } ;
26
34
content : WordContent [ ] ;
27
35
} ;
28
36
@@ -35,29 +43,89 @@ function Component() {
35
43
const { participants, words } = Route . useLoaderData ( ) ;
36
44
const editorRef = useRef ( null ) ;
37
45
38
- const content = {
39
- type : "doc" ,
40
- content : words . reduce < { cur : number | null ; acc : SpeakerContent [ ] } > ( ( state , word ) => {
41
- if ( state . cur !== word . speaker ) {
42
- state . cur = word . speaker ;
43
- state . acc . push ( {
44
- type : "speaker" ,
45
- attrs : { label : word . speaker === null ? "" : `Speaker ${ word . speaker } ` } ,
46
- content : [ ] ,
47
- } ) ;
48
- }
46
+ const [ content , _d ] = useState ( fromWordsToEditor ( words ) ) ;
49
47
50
- if ( state . acc . length > 0 ) {
51
- state . acc [ state . acc . length - 1 ] . content . push ( {
52
- type : "word" ,
53
- content : [ { type : "text" , text : word . text } ] ,
48
+ return (
49
+ < div className = "px-6 py-2" >
50
+ < TranscriptToolbar editorRef = { editorRef } />
51
+
52
+ < div className = "flex-1 overflow-auto min-h-0" >
53
+ < TranscriptEditor
54
+ ref = { editorRef }
55
+ initialContent = { content }
56
+ speakers = { participants . map ( ( p ) => ( { id : p . id , name : p . full_name ?? "Unknown" } ) ) }
57
+ />
58
+ </ div >
59
+ </ div >
60
+ ) ;
61
+ }
62
+
63
+ function TranscriptToolbar ( { editorRef } : { editorRef : React . RefObject < any > } ) {
64
+ const { id } = useParams ( { from : "/app/transcript/$id" } ) ;
65
+
66
+ const title = useQuery ( {
67
+ queryKey : [ "session-title" , id ] ,
68
+ queryFn : ( ) => dbCommands . getSession ( { id } ) . then ( ( v ) => v ?. title ) ,
69
+ } ) ;
70
+
71
+ const handleSave = ( ) => {
72
+ const content = editorRef . current ?. editor . getJSON ( ) ;
73
+ const words = fromEditorToWords ( content ) ;
74
+
75
+ dbCommands . getSession ( { id } ) . then ( ( session ) => {
76
+ if ( session ) {
77
+ dbCommands . upsertSession ( {
78
+ ...session ,
79
+ words,
54
80
} ) ;
55
81
}
82
+ } ) . then ( ( ) => {
83
+ windowsCommands . windowDestroy ( { type : "transcript" , value : id } ) ;
84
+ } ) ;
85
+ } ;
56
86
57
- return state ;
58
- } , { cur : null , acc : [ ] } ) . acc ,
87
+ const handleCancel = ( ) => {
88
+ windowsCommands . windowDestroy ( { type : "transcript" , value : id } ) ;
59
89
} ;
60
90
91
+ return (
92
+ < header
93
+ data-tauri-drag-region
94
+ className = "flex w-full items-center justify-between min-h-11 p-1 px-3 border-b border-border bg-background/80 backdrop-blur-sm"
95
+ >
96
+ < div className = "w-20 ml-20" >
97
+ < SearchAndReplace editorRef = { editorRef } />
98
+ </ div >
99
+
100
+ < div
101
+ className = "flex-1 flex items-center justify-center"
102
+ data-tauri-drag-region
103
+ >
104
+ < div className = "flex items-center gap-2" >
105
+ < PencilIcon className = "w-3 h-3 text-muted-foreground" />
106
+ < h1 className = "text-sm font-light truncate max-w-md" data-tauri-drag-region >
107
+ (Transcript) { title . data }
108
+ </ h1 >
109
+ </ div >
110
+ </ div >
111
+
112
+ < div className = "w-40 flex justify-end gap-2" >
113
+ < Button
114
+ variant = "ghost"
115
+ size = "sm"
116
+ onClick = { handleCancel }
117
+ >
118
+ Cancel
119
+ </ Button >
120
+ < Button size = "sm" onClick = { handleSave } >
121
+ Save
122
+ </ Button >
123
+ </ div >
124
+ </ header >
125
+ ) ;
126
+ }
127
+
128
+ function SearchAndReplace ( { editorRef } : { editorRef : React . RefObject < any > } ) {
61
129
const [ expanded , setExpanded ] = useState ( false ) ;
62
130
const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
63
131
const [ replaceTerm , setReplaceTerm ] = useState ( "" ) ;
@@ -84,55 +152,132 @@ function Component() {
84
152
if ( editorRef . current && searchTerm ) {
85
153
// @ts -ignore
86
154
editorRef . current . editor . commands . replaceAll ( replaceTerm ) ;
87
- // setExpanded(false);
88
- // TODO: we need editor state updated first.
155
+ setExpanded ( false ) ;
156
+ setSearchTerm ( "" ) ;
157
+ setReplaceTerm ( "" ) ;
89
158
}
90
159
} ;
91
160
92
161
return (
93
- < div className = "p-6 flex-1 flex flex-col overflow-hidden" >
94
- < Popover open = { expanded } onOpenChange = { setExpanded } >
95
- < PopoverTrigger asChild >
162
+ < Popover open = { expanded } onOpenChange = { setExpanded } >
163
+ < PopoverTrigger asChild >
164
+ < Button
165
+ className = "w-8"
166
+ variant = "default"
167
+ size = "icon"
168
+ >
169
+ < ReplaceAllIcon size = { 12 } />
170
+ </ Button >
171
+ </ PopoverTrigger >
172
+ < PopoverContent className = "w-full p-2" >
173
+ < div className = "flex flex-row gap-2" >
174
+ < Input
175
+ className = "h-6"
176
+ value = { searchTerm }
177
+ onChange = { ( e ) => setSearchTerm ( e . target . value ) }
178
+ placeholder = "Search"
179
+ />
180
+ < Input
181
+ className = "h-6"
182
+ value = { replaceTerm }
183
+ onChange = { ( e ) => setReplaceTerm ( e . target . value ) }
184
+ placeholder = "Replace"
185
+ />
96
186
< Button
97
- className = "w-8 "
187
+ className = "h-6 "
98
188
variant = "default"
99
- size = "icon"
189
+ onClick = { handleReplaceAll }
100
190
>
101
- < ReplaceAllIcon size = { 12 } />
191
+ Replace
102
192
</ Button >
103
- </ PopoverTrigger >
104
- < PopoverContent className = "w-full p-2" >
105
- < div className = "flex flex-row gap-2" >
106
- < Input
107
- className = "h-6"
108
- value = { searchTerm }
109
- onChange = { ( e ) => setSearchTerm ( e . target . value ) }
110
- placeholder = "Search"
111
- />
112
- < Input
113
- className = "h-6"
114
- value = { replaceTerm }
115
- onChange = { ( e ) => setReplaceTerm ( e . target . value ) }
116
- placeholder = "Replace"
117
- />
118
- < Button
119
- className = "h-6"
120
- variant = "default"
121
- onClick = { handleReplaceAll }
122
- >
123
- Replace
124
- </ Button >
125
- </ div >
126
- </ PopoverContent >
127
- </ Popover >
128
-
129
- < div className = "h-full overflow-auto" >
130
- < TranscriptEditor
131
- ref = { editorRef }
132
- initialContent = { content }
133
- speakers = { participants . map ( ( p ) => ( { id : p . id , name : p . full_name ?? "Unknown" } ) ) }
134
- />
135
- </ div >
136
- </ div >
193
+ </ div >
194
+ </ PopoverContent >
195
+ </ Popover >
137
196
) ;
138
197
}
198
+
199
+ const fromWordsToEditor = ( words : Word [ ] ) : EditorContent => {
200
+ return {
201
+ type : "doc" ,
202
+ content : words . reduce < { cur : SpeakerIdentity | null ; acc : SpeakerContent [ ] } > ( ( state , word ) => {
203
+ const isFirst = state . acc . length === 0 ;
204
+
205
+ const isSameSpeaker = ( ! state . cur && ! word . speaker )
206
+ || ( state . cur ?. type === "unassigned" && word . speaker ?. type === "unassigned"
207
+ && state . cur . value . index === word . speaker . value . index )
208
+ || ( state . cur ?. type === "assigned" && word . speaker ?. type === "assigned"
209
+ && state . cur . value . id === word . speaker . value . id ) ;
210
+
211
+ if ( isFirst || ! isSameSpeaker ) {
212
+ state . cur = word . speaker ;
213
+
214
+ state . acc . push ( {
215
+ type : "speaker" ,
216
+ attrs : {
217
+ "speaker-index" : word . speaker ?. type === "unassigned" ? word . speaker . value ?. index : null ,
218
+ "speaker-id" : word . speaker ?. type === "assigned" ? word . speaker . value ?. id : null ,
219
+ "speaker-label" : word . speaker ?. type === "assigned" ? word . speaker . value ?. label || "" : null ,
220
+ } ,
221
+ content : [ ] ,
222
+ } ) ;
223
+ }
224
+
225
+ if ( state . acc . length > 0 ) {
226
+ state . acc [ state . acc . length - 1 ] . content . push ( {
227
+ type : "word" ,
228
+ content : [ { type : "text" , text : word . text } ] ,
229
+ } ) ;
230
+ }
231
+
232
+ return state ;
233
+ } , { cur : null , acc : [ ] } ) . acc ,
234
+ } ;
235
+ } ;
236
+
237
+ const fromEditorToWords = ( content : EditorContent ) : Word [ ] => {
238
+ if ( ! content ?. content ) {
239
+ return [ ] ;
240
+ }
241
+
242
+ const words : Word [ ] = [ ] ;
243
+
244
+ for ( const speakerBlock of content . content ) {
245
+ if ( speakerBlock . type !== "speaker" || ! speakerBlock . content ) {
246
+ continue ;
247
+ }
248
+
249
+ let speaker : SpeakerIdentity | null = null ;
250
+ if ( speakerBlock . attrs [ "speaker-id" ] ) {
251
+ speaker = {
252
+ type : "assigned" ,
253
+ value : {
254
+ id : speakerBlock . attrs [ "speaker-id" ] ,
255
+ label : speakerBlock . attrs [ "speaker-label" ] ?? "" ,
256
+ } ,
257
+ } ;
258
+ } else if ( typeof speakerBlock . attrs [ "speaker-index" ] === "number" ) {
259
+ speaker = {
260
+ type : "unassigned" ,
261
+ value : {
262
+ index : speakerBlock . attrs [ "speaker-index" ] ,
263
+ } ,
264
+ } ;
265
+ }
266
+
267
+ for ( const wordBlock of speakerBlock . content ) {
268
+ if ( wordBlock . type !== "word" || ! wordBlock . content ?. [ 0 ] ?. text ) {
269
+ continue ;
270
+ }
271
+
272
+ words . push ( {
273
+ text : wordBlock . content [ 0 ] . text ,
274
+ speaker,
275
+ confidence : 1 ,
276
+ start_ms : 0 ,
277
+ end_ms : 0 ,
278
+ } ) ;
279
+ }
280
+ }
281
+
282
+ return words ;
283
+ } ;
0 commit comments