@linto-ai/transcript-ui-core 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +46 -0
  3. package/package.json +46 -0
  4. package/src/adapters/apiAdapter.ts +44 -0
  5. package/src/adapters/mapApiTurns.ts +32 -0
  6. package/src/adapters/test/mapApiTurns.test.ts +73 -0
  7. package/src/adapters/whisperXAdapter.ts +73 -0
  8. package/src/components/ChannelSelector.vue +31 -0
  9. package/src/components/EditorErrorOverlay.vue +55 -0
  10. package/src/components/EditorLoadingOverlay.vue +52 -0
  11. package/src/components/Header.vue +138 -0
  12. package/src/components/Layout.vue +221 -0
  13. package/src/components/MicrophoneIllustration.vue +28 -0
  14. package/src/components/SelectionActionBar.vue +86 -0
  15. package/src/components/SidebarDrawer.vue +32 -0
  16. package/src/components/SpeakerLabel.vue +105 -0
  17. package/src/components/SpeakerSidebar.vue +455 -0
  18. package/src/components/TabBar.constants.ts +2 -0
  19. package/src/components/TabBar.vue +52 -0
  20. package/src/components/TranscriptUI.vue +68 -0
  21. package/src/components/TranscriptionEmpty.vue +39 -0
  22. package/src/components/TranscriptionPanel.vue +275 -0
  23. package/src/components/TranscriptionTurn.vue +407 -0
  24. package/src/components/TranslationSelector.vue +39 -0
  25. package/src/components/VerbatimPanel.vue +160 -0
  26. package/src/components/molecules/MergeDialog.vue +150 -0
  27. package/src/components/molecules/MergeTurnsButton.vue +57 -0
  28. package/src/components/molecules/SpeakerPopover.vue +146 -0
  29. package/src/composables/useEditorReady.ts +62 -0
  30. package/src/composables/useFollowPlayback.ts +124 -0
  31. package/src/composables/useIsMobile.ts +24 -0
  32. package/src/composables/useTurnSelection.ts +169 -0
  33. package/src/constants/speakers.ts +14 -0
  34. package/src/core/createCore.ts +162 -0
  35. package/src/core/helpers/addSpeaker.ts +11 -0
  36. package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
  37. package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
  38. package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
  39. package/src/core/helpers/ensureSpeaker.ts +12 -0
  40. package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
  41. package/src/core/helpers/findTurnIndex.ts +5 -0
  42. package/src/core/helpers/index.ts +15 -0
  43. package/src/core/helpers/insertTurn.ts +5 -0
  44. package/src/core/helpers/mergeSpeakers.ts +29 -0
  45. package/src/core/helpers/patchTurn.ts +15 -0
  46. package/src/core/helpers/prependTurns.ts +5 -0
  47. package/src/core/helpers/removeTurn.ts +8 -0
  48. package/src/core/helpers/renameSpeaker.ts +12 -0
  49. package/src/core/helpers/speakerEquals.ts +6 -0
  50. package/src/core/helpers/switchTurnSpeaker.ts +17 -0
  51. package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
  52. package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
  53. package/src/core/helpers/test/makeTestCore.ts +40 -0
  54. package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
  55. package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
  56. package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
  57. package/src/core/helpers/updateTurnWords.ts +22 -0
  58. package/src/core/index.ts +47 -0
  59. package/src/core/modules/eventBus.ts +43 -0
  60. package/src/core/stores/channelStore.ts +100 -0
  61. package/src/core/stores/crossTranslationStore.ts +141 -0
  62. package/src/core/stores/index.ts +7 -0
  63. package/src/core/stores/speakersStore.ts +53 -0
  64. package/src/core/stores/translationStore.ts +133 -0
  65. package/src/core/types.ts +602 -0
  66. package/src/core/useCore.ts +16 -0
  67. package/src/index.ts +88 -0
  68. package/src/types/api.ts +52 -0
  69. package/src/types/editor.ts +65 -0
  70. package/src/types/whisperx.ts +22 -0
  71. package/src/utils/color.ts +7 -0
  72. package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
  73. package/src/utils/computeTurnPlainText.ts +9 -0
  74. package/src/utils/extractLangCode.ts +7 -0
  75. package/src/utils/findWordAtOffset.ts +16 -0
  76. package/src/utils/index.ts +19 -0
  77. package/src/utils/intl.ts +47 -0
  78. package/src/utils/isSameLanguage.ts +13 -0
  79. package/src/utils/test/computeTurnPlainText.test.ts +34 -0
  80. package/src/utils/test/findWordAtOffset.test.ts +25 -0
  81. package/src/utils/throttle.ts +23 -0
  82. package/src/utils/time.ts +95 -0
  83. package/src/utils/tokenize.ts +34 -0
  84. package/src/utils/tts.ts +49 -0
  85. package/src/utils/turnWords/carryWordTimes.ts +37 -0
  86. package/src/utils/turnWords/index.ts +6 -0
  87. package/src/utils/turnWords/layoutWords.ts +41 -0
  88. package/src/utils/turnWords/parseWordId.ts +13 -0
  89. package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
  90. package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
  91. package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
  92. package/src/utils/turnWords/test/wordId.test.ts +9 -0
  93. package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
  94. package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
  95. package/src/utils/turnWords/wordId.ts +9 -0
  96. package/src/utils/turnWords/wordsFromApi.ts +16 -0
  97. package/src/utils/turnWords/wordsFromText.ts +14 -0
  98. package/src/utils/validateDocument.ts +80 -0
  99. package/src/utils/waveform.ts +64 -0
  100. package/src/utils/wordRange.ts +67 -0
  101. package/src/utils/words.ts +45 -0
@@ -0,0 +1,455 @@
1
+ <script setup lang="ts">
2
+ import { SpeakerIndicator, SwitchToggle, EditableText, SelectableListItem, EditorIcon, SpeakerMenu } from "@linto-ai/transcript-ui-ui"
3
+ import { computed, ref } from "vue"
4
+ import ChannelSelector from "./ChannelSelector.vue"
5
+ import TranslationSelector from "./TranslationSelector.vue"
6
+ import MergeDialog from "./molecules/MergeDialog.vue"
7
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
8
+ import { useCore } from "../core"
9
+ import { renameSpeaker } from "../core/helpers"
10
+ import type { Speaker } from "../types/editor"
11
+ import type { LLMServiceGenerationStatus, TranslationInfo } from "../core"
12
+
13
+ withDefaults(
14
+ defineProps<{
15
+ speakers: Speaker[]
16
+ channels: { id: string; name: string }[]
17
+ selectedChannelId: string
18
+ translations: TranslationInfo[]
19
+ selectedTranslationId: string
20
+ showSpeakers?: boolean
21
+ }>(),
22
+ { showSpeakers: true },
23
+ )
24
+
25
+ defineEmits<{
26
+ "update:selectedChannelId": [id: string]
27
+ "update:selectedTranslationId": [id: string]
28
+ }>()
29
+
30
+ const core = useCore()
31
+ const { t } = useI18n()
32
+
33
+ const canEditSpeakers = computed(
34
+ () => core.capabilities.value.speakers === "edit",
35
+ )
36
+
37
+ const ttsReady = computed(() => core.live?.ttsReady.value ?? false)
38
+ const ttsHint = computed(() =>
39
+ ttsReady.value
40
+ ? t("voicePlayback.description")
41
+ : t("voicePlayback.unavailable"),
42
+ )
43
+
44
+ function onToggleTts(value: boolean): void {
45
+ if (!core.live || !ttsReady.value) return
46
+ if (value) core.live.enableTTS()
47
+ else core.live.disableTTS()
48
+ }
49
+
50
+ const mergeOpen = ref(false)
51
+ const mergeFromId = ref<string | null>(null)
52
+
53
+ function onRename(speakerId: string, newName: string): void {
54
+ // Through the plugin when present (server round-trip, applied at the
55
+ // broadcast); direct store helper otherwise (plugin-less embeds).
56
+ if (core.transcriptionEditor) {
57
+ core.transcriptionEditor.renameSpeaker(speakerId, newName)
58
+ } else {
59
+ renameSpeaker(core, speakerId, newName)
60
+ }
61
+ }
62
+
63
+ function onOpenMerge(speakerId: string): void {
64
+ mergeFromId.value = speakerId
65
+ mergeOpen.value = true
66
+ }
67
+
68
+ // ── History (LLM service generations + versions) ──────────────────────
69
+
70
+ const activeService = computed(() => core.llmServices?.active.value ?? null)
71
+
72
+ const dateFormat = new Intl.DateTimeFormat(undefined, {
73
+ dateStyle: "short",
74
+ timeStyle: "short",
75
+ })
76
+
77
+ const generations = computed(() => {
78
+ const list = activeService.value?.generations.value ?? []
79
+ return [...list].sort((a, b) => b.createdAt - a.createdAt)
80
+ })
81
+
82
+ const versions = computed(() => {
83
+ const list = activeService.value?.versions.value ?? []
84
+ return [...list].sort((a, b) => b.versionNumber - a.versionNumber)
85
+ })
86
+
87
+ const currentGenerationId = computed(
88
+ () => activeService.value?.currentGenerationId.value ?? null,
89
+ )
90
+ const activeVersionNumber = computed(
91
+ () => activeService.value?.activeVersionNumber.value ?? null,
92
+ )
93
+ const serviceBusy = computed(() => activeService.value?.busy.value ?? false)
94
+
95
+ function statusIconName(status: LLMServiceGenerationStatus): string {
96
+ if (status === "completed") return "check"
97
+ if (status === "error") return "x"
98
+ return "spinner"
99
+ }
100
+
101
+ function statusLabel(status: LLMServiceGenerationStatus): string {
102
+ if (status === "completed") return t("sidebar.statusCompleted")
103
+ if (status === "error") return t("sidebar.statusError")
104
+ if (status === "processing") return t("sidebar.statusProcessing")
105
+ return t("sidebar.statusQueued")
106
+ }
107
+
108
+ function onSelectGeneration(generationId: string): void {
109
+ if (serviceBusy.value) return
110
+ if (generationId === currentGenerationId.value) return
111
+ const service = activeService.value
112
+ if (!service) return
113
+ core.emit("llmService:selectGeneration", { id: service.id, generationId })
114
+ }
115
+
116
+ function onSelectVersion(versionNumber: number): void {
117
+ if (serviceBusy.value) return
118
+ if (versionNumber === activeVersionNumber.value) return
119
+ const service = activeService.value
120
+ if (!service) return
121
+ core.emit("llmService:selectVersion", { id: service.id, versionNumber })
122
+ }
123
+ </script>
124
+
125
+ <template>
126
+ <aside class="speaker-sidebar">
127
+ <section
128
+ v-if="channels.length > 1"
129
+ class="sidebar-section sidebar-section--selector">
130
+ <h2 class="sidebar-title">{{ t("sidebar.channel") }}</h2>
131
+ <ChannelSelector
132
+ :channels="channels"
133
+ :selected-channel-id="selectedChannelId"
134
+ @update:selected-channel-id="
135
+ $emit('update:selectedChannelId', $event)
136
+ " />
137
+ </section>
138
+ <section
139
+ v-if="translations.length > 1"
140
+ class="sidebar-section sidebar-section--selector">
141
+ <h2 class="sidebar-title">{{ t("sidebar.translation") }}</h2>
142
+ <TranslationSelector
143
+ :translations="translations"
144
+ :selected-translation-id="selectedTranslationId"
145
+ @update:selected-translation-id="
146
+ $emit('update:selectedTranslationId', $event)
147
+ " />
148
+ </section>
149
+ <section v-if="core.subtitle" class="sidebar-section">
150
+ <h2 class="sidebar-title">{{ t("sidebar.subtitle") }}</h2>
151
+ <div class="subtitle-toggle">
152
+ <span class="subtitle-toggle-label">{{ t("subtitle.show") }}</span>
153
+ <SwitchToggle v-model="core.subtitle.isVisible.value" />
154
+ </div>
155
+ <label class="subtitle-slider">
156
+ <span class="subtitle-slider-label">
157
+ {{ t("subtitle.fontSize") }}
158
+ <span class="subtitle-slider-value"
159
+ >{{ core.subtitle.fontSize.value }}px</span
160
+ >
161
+ </span>
162
+ <input
163
+ type="range"
164
+ :min="20"
165
+ :max="80"
166
+ :step="2"
167
+ :value="core.subtitle.fontSize.value"
168
+ :disabled="!core.subtitle.isVisible.value"
169
+ @input="
170
+ core.subtitle!.fontSize.value = Number(
171
+ ($event.target as HTMLInputElement).value,
172
+ )
173
+ " />
174
+ </label>
175
+ <div
176
+ v-if="core.subtitle.watermark && !core.subtitle.watermark.readonly"
177
+ class="subtitle-toggle">
178
+ <span class="subtitle-toggle-label">{{
179
+ t("subtitle.showWatermark")
180
+ }}</span>
181
+ <SwitchToggle
182
+ v-model="core.subtitle.watermark.display.value"
183
+ :disabled="!core.subtitle.isVisible.value" />
184
+ </div>
185
+ <div
186
+ v-if="
187
+ core.subtitle.watermark &&
188
+ !core.subtitle.watermark.readonly &&
189
+ core.subtitle.watermark.display.value
190
+ "
191
+ class="subtitle-toggle">
192
+ <span class="subtitle-toggle-label">{{
193
+ t("subtitle.pinWatermark")
194
+ }}</span>
195
+ <SwitchToggle
196
+ v-model="core.subtitle.watermark.pinned.value"
197
+ :disabled="!core.subtitle.isVisible.value" />
198
+ </div>
199
+ </section>
200
+ <section v-if="core.live && core.live.ttsAvailable" class="sidebar-section">
201
+ <h2 class="sidebar-title">{{ t("sidebar.voicePlayback") }}</h2>
202
+ <div class="subtitle-toggle">
203
+ <span class="subtitle-toggle-label">{{
204
+ t("voicePlayback.enable")
205
+ }}</span>
206
+ <SwitchToggle
207
+ :model-value="core.live.ttsEnabled.value"
208
+ :disabled="!ttsReady"
209
+ @update:model-value="onToggleTts" />
210
+ </div>
211
+ <p
212
+ class="voice-playback-hint"
213
+ :class="{ 'voice-playback-hint--warning': !ttsReady }">
214
+ {{ ttsHint }}
215
+ </p>
216
+ </section>
217
+ <section
218
+ v-if="activeService && generations.length"
219
+ class="sidebar-section"
220
+ :class="{ 'sidebar-section--busy': serviceBusy }">
221
+ <h2 class="sidebar-title">{{ t("sidebar.history") }}</h2>
222
+ <ul class="history-list">
223
+ <li
224
+ v-for="gen in generations"
225
+ :key="gen.generationId"
226
+ class="history-generation"
227
+ :class="{
228
+ 'history-generation--current':
229
+ gen.generationId === currentGenerationId,
230
+ }">
231
+ <SelectableListItem
232
+ :current="gen.generationId === currentGenerationId"
233
+ :disabled="serviceBusy"
234
+ @select="onSelectGeneration(gen.generationId)">
235
+ <template #leading>
236
+ <EditorIcon
237
+ :name="statusIconName(gen.status)"
238
+ :spin="gen.status === 'processing' || gen.status === 'queued'"
239
+ :size="14"
240
+ :class="`history-generation__status--${gen.status}`" />
241
+ </template>
242
+ <time :datetime="new Date(gen.createdAt).toISOString()">
243
+ {{ dateFormat.format(gen.createdAt) }}
244
+ </time>
245
+ <template #trailing>{{ statusLabel(gen.status) }}</template>
246
+ </SelectableListItem>
247
+ <ul
248
+ v-if="gen.generationId === currentGenerationId && versions.length"
249
+ class="history-version-list">
250
+ <li v-for="v in versions" :key="v.versionNumber">
251
+ <SelectableListItem
252
+ size="sm"
253
+ :current="v.versionNumber === activeVersionNumber"
254
+ :disabled="serviceBusy"
255
+ @select="onSelectVersion(v.versionNumber)">
256
+ v{{ v.versionNumber }}
257
+ <template #trailing>
258
+ <time :datetime="new Date(v.createdAt).toISOString()">
259
+ {{ dateFormat.format(v.createdAt) }}
260
+ </time>
261
+ </template>
262
+ </SelectableListItem>
263
+ </li>
264
+ </ul>
265
+ </li>
266
+ </ul>
267
+ </section>
268
+ <section v-if="showSpeakers && speakers.length" class="sidebar-section">
269
+ <h2 class="sidebar-title">{{ t("sidebar.speakers") }}</h2>
270
+ <ul class="speaker-list">
271
+ <li v-for="speaker in speakers" :key="speaker.id" class="speaker-item">
272
+ <SpeakerIndicator :color="speaker.color" />
273
+ <EditableText
274
+ class="speaker-name"
275
+ :model-value="speaker.name"
276
+ :disabled="!canEditSpeakers"
277
+ :aria-label="t('sidebar.renameSpeaker')"
278
+ @commit="onRename(speaker.id, $event)" />
279
+ <SpeakerMenu
280
+ v-if="canEditSpeakers && speakers.length > 1"
281
+ :speaker-name="speaker.name"
282
+ @merge="onOpenMerge(speaker.id)" />
283
+ </li>
284
+ </ul>
285
+ </section>
286
+ <MergeDialog
287
+ v-if="canEditSpeakers"
288
+ v-model:open="mergeOpen"
289
+ :from-speaker-id="mergeFromId" />
290
+ </aside>
291
+ </template>
292
+
293
+ <style scoped>
294
+ .speaker-sidebar {
295
+ display: flex;
296
+ flex-direction: column;
297
+ gap: var(--spacing-lg);
298
+ padding: var(--spacing-lg);
299
+ border-left: 1px solid var(--color-border);
300
+ background-color: var(--color-surface);
301
+ overflow-y: auto;
302
+ }
303
+
304
+ .sidebar-section {
305
+ display: flex;
306
+ flex-direction: column;
307
+ gap: var(--spacing-sm);
308
+ }
309
+
310
+ .sidebar-title {
311
+ font-size: var(--font-size-sm);
312
+ font-weight: 600;
313
+ color: var(--color-text-muted);
314
+ text-transform: uppercase;
315
+ letter-spacing: 0.05em;
316
+ }
317
+
318
+ .speaker-list {
319
+ list-style: none;
320
+ display: flex;
321
+ flex-direction: column;
322
+ gap: var(--spacing-xs);
323
+ }
324
+
325
+ .speaker-item {
326
+ display: flex;
327
+ align-items: center;
328
+ gap: var(--spacing-sm);
329
+ padding: var(--spacing-sm);
330
+ border-radius: var(--radius-md);
331
+ transition: background-color var(--transition-duration);
332
+ }
333
+
334
+ .speaker-item:hover {
335
+ background-color: var(--color-surface-hover);
336
+ }
337
+
338
+ .speaker-name {
339
+ flex: 1;
340
+ font-size: var(--font-size-sm);
341
+ font-weight: 500;
342
+ color: var(--color-text-primary);
343
+ }
344
+
345
+ .subtitle-toggle {
346
+ display: flex;
347
+ align-items: center;
348
+ justify-content: space-between;
349
+ padding: var(--spacing-sm);
350
+ border-radius: var(--radius-md);
351
+ }
352
+
353
+ .subtitle-toggle-label {
354
+ font-size: var(--font-size-sm);
355
+ color: var(--color-text-primary);
356
+ }
357
+
358
+ .voice-playback-hint {
359
+ padding: 0 var(--spacing-sm);
360
+ font-size: var(--font-size-xs);
361
+ color: var(--color-text-muted);
362
+ }
363
+
364
+ .voice-playback-hint--warning {
365
+ color: var(--color-danger);
366
+ }
367
+
368
+ .subtitle-slider {
369
+ display: flex;
370
+ flex-direction: column;
371
+ gap: var(--spacing-xs);
372
+ padding: var(--spacing-sm);
373
+ }
374
+
375
+ .subtitle-slider-label {
376
+ display: flex;
377
+ justify-content: space-between;
378
+ font-size: var(--font-size-sm);
379
+ color: var(--color-text-primary);
380
+ }
381
+
382
+ .subtitle-slider-value {
383
+ color: var(--color-text-muted);
384
+ font-variant-numeric: tabular-nums;
385
+ }
386
+
387
+ .subtitle-slider input[type="range"] {
388
+ width: 100%;
389
+ accent-color: var(--color-primary);
390
+ }
391
+
392
+ .subtitle-slider input[type="range"]:disabled {
393
+ opacity: 0.4;
394
+ cursor: not-allowed;
395
+ }
396
+
397
+ /* ── History (LLM generations + versions) ──────────────────────────── */
398
+
399
+ .sidebar-section--busy {
400
+ opacity: 0.6;
401
+ pointer-events: none;
402
+ }
403
+
404
+ .history-list {
405
+ list-style: none;
406
+ display: flex;
407
+ flex-direction: column;
408
+ gap: var(--spacing-xs);
409
+ margin: 0;
410
+ padding: 0;
411
+ }
412
+
413
+ .history-generation {
414
+ display: flex;
415
+ flex-direction: column;
416
+ }
417
+
418
+ .history-generation__status--completed {
419
+ color: var(--color-success, #2e7d32);
420
+ }
421
+
422
+ .history-generation__status--error {
423
+ color: var(--color-danger, #d33);
424
+ }
425
+
426
+ .history-generation__status--processing,
427
+ .history-generation__status--queued {
428
+ color: var(--color-primary);
429
+ }
430
+
431
+ .history-version-list {
432
+ list-style: none;
433
+ display: flex;
434
+ flex-direction: column;
435
+ gap: 2px;
436
+ margin: var(--spacing-xs) 0 0 var(--spacing-md);
437
+ padding: 0;
438
+ border-left: 1px solid var(--color-border);
439
+ }
440
+
441
+ /* Nudge nested version rows off the connecting border line. */
442
+ .history-version-list :deep(.selectable-list-item) {
443
+ margin-left: var(--spacing-xs);
444
+ }
445
+
446
+ @media (max-width: 767px) {
447
+ .speaker-sidebar {
448
+ border-left: none;
449
+ }
450
+
451
+ .sidebar-section--selector {
452
+ display: none;
453
+ }
454
+ }
455
+ </style>
@@ -0,0 +1,2 @@
1
+ export const TRANSCRIPTION_TAB = "__transcription__"
2
+ export const VERBATIM_TAB = "__verbatim__"
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { Tabs, type TabItem } from "@linto-ai/transcript-ui-ui"
3
+ import { computed } from "vue"
4
+ import { TRANSCRIPTION_TAB, VERBATIM_TAB } from "./TabBar.constants"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+ import { useCore } from "../core"
7
+
8
+ const props = defineProps<{
9
+ modelValue: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ "update:modelValue": [value: string]
14
+ }>()
15
+
16
+ const core = useCore()
17
+ const { t } = useI18n()
18
+
19
+ const tabs = computed<TabItem[]>(() => {
20
+ const services = core.llmServices?.list.value ?? []
21
+ return [
22
+ {
23
+ value: TRANSCRIPTION_TAB,
24
+ label: t("tabs.transcription"),
25
+ icon: "message-circle",
26
+ },
27
+ {
28
+ value: VERBATIM_TAB,
29
+ label: t("tabs.verbatim"),
30
+ icon: "file-text",
31
+ },
32
+ ...services.map<TabItem>((service) => ({
33
+ value: service.id,
34
+ label: service.label.value,
35
+ icon: "sparkles",
36
+ badge: t("tabs.aiBadge"),
37
+ })),
38
+ ]
39
+ })
40
+
41
+ function onSelect(value: string): void {
42
+ if (value !== props.modelValue) emit("update:modelValue", value)
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <Tabs
48
+ v-if="core.llmServices"
49
+ :tabs="tabs"
50
+ :model-value="modelValue"
51
+ @update:model-value="onSelect" />
52
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onBeforeUnmount } from "vue"
3
+ import Layout from "./Layout.vue"
4
+ import EditorLoadingOverlay from "./EditorLoadingOverlay.vue"
5
+ import EditorErrorOverlay from "./EditorErrorOverlay.vue"
6
+ import { createCore } from "../core/createCore"
7
+ import { provideCore } from "../core/useCore"
8
+ import { useEditorReady } from "../composables/useEditorReady"
9
+ import { provideI18n, type Locale } from "@linto-ai/transcript-ui-i18n"
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ locale?: string
14
+ noHeader?: boolean
15
+ }>(),
16
+ {
17
+ locale: "fr",
18
+ noHeader: false,
19
+ },
20
+ )
21
+
22
+ const locale = ref<Locale>(props.locale as Locale)
23
+ provideI18n(locale)
24
+
25
+ watch(
26
+ () => props.locale,
27
+ (val) => {
28
+ locale.value = val as Locale
29
+ },
30
+ )
31
+
32
+ const core = createCore()
33
+ provideCore(core)
34
+
35
+ // The editor owns its loading/error overlays: loading stays up until the
36
+ // document is loaded and (in collab mode) the first sync lands; error shows
37
+ // when the load fails non-recoverably. Embedders don't poll or render either.
38
+ const { isLoading, error } = useEditorReady(core)
39
+
40
+ // Destroy before Vue tears down the child tree, else the live ProseMirror view
41
+ // reconciles node views whose DOM is being removed ("nextSibling" null crash).
42
+ onBeforeUnmount(() => core.destroy())
43
+
44
+ defineExpose({ core })
45
+ </script>
46
+
47
+ <template>
48
+ <div class="editor-root">
49
+ <Layout
50
+ v-if="core.channels.size"
51
+ :show-header="!props.noHeader" />
52
+ <EditorErrorOverlay v-if="error" :message="error" />
53
+ <EditorLoadingOverlay v-else-if="isLoading" />
54
+ </div>
55
+ </template>
56
+
57
+ <style lang="css">
58
+ @import "@linto-ai/transcript-ui-ui/styles/fonts.css";
59
+ @import "@linto-ai/transcript-ui-ui/styles/variables.css";
60
+ @import "@linto-ai/transcript-ui-ui/styles/base.css";
61
+ @import "@linto-ai/transcript-ui-ui/styles/popover-list.css";
62
+
63
+ /* Positioning context for the absolute loading overlay. */
64
+ .editor-root {
65
+ position: relative;
66
+ height: 100%;
67
+ }
68
+ </style>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
3
+ import MicrophoneIllustration from "./MicrophoneIllustration.vue"
4
+
5
+ const { t } = useI18n()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="transcription-empty">
10
+ <MicrophoneIllustration class="illustration" aria-hidden="true" />
11
+ <p class="message">{{ t("transcription.empty") }}</p>
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .transcription-empty {
17
+ flex: 1;
18
+ display: flex;
19
+ flex-direction: column;
20
+ align-items: center;
21
+ justify-content: center;
22
+ gap: var(--spacing-lg);
23
+ padding: var(--spacing-xl);
24
+ }
25
+
26
+ .illustration {
27
+ width: 180px;
28
+ height: auto;
29
+ color: var(--color-text-muted);
30
+ opacity: 0.5;
31
+ }
32
+
33
+ .message {
34
+ color: var(--color-text-muted);
35
+ font-size: var(--font-size-sm);
36
+ text-align: center;
37
+ margin: 0;
38
+ }
39
+ </style>