@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
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @linto-ai/transcript-ui-core
2
+
3
+ The main piece of @linto-ai/transcript-ui — a Vue component for displaying and interacting with a transcript. It bundles the document state (`createCore`), the editor layout (`Layout`), and adapters that convert LinTO Studio API or WhisperX transcripts into a format it understands.
4
+
5
+ ## Usage
6
+
7
+ ```vue
8
+ <script setup lang="ts">
9
+ import { TranscriptUI, mapApiDocument } from "@linto-ai/transcript-ui-core"
10
+ </script>
11
+
12
+ <template>
13
+ <TranscriptUI ref="editor" locale="en" />
14
+ </template>
15
+ ```
16
+
17
+ `TranscriptUI` handles everything on its own: it creates the document state, shows a loading spinner, then the transcript once a document is loaded.
18
+
19
+ ## Building your own layout instead
20
+
21
+ If you don't want `TranscriptUI`'s default screen, `createCore`/`provideCore` and `Layout` are the two pieces it uses internally — use them directly to build your own.
22
+
23
+ ## Turning a transcript into something this package understands
24
+
25
+ - `mapApiDocument` — for a transcript from the LinTO Studio API.
26
+ - `mapWhisperXDocument` — for raw WhisperX output.
27
+
28
+ ## Data model
29
+
30
+ A document (`EditorDocument`) has a flat list of speakers and one or more channels.
31
+
32
+ - **Channel** — one audio/recording track (e.g. one microphone, one room). Has one or more `Translation`s.
33
+ - **Translation** — one language track within a channel: the source language, or an auto-translated version. Exactly one per channel is the source (`isSource: true`). Holds an ordered list of `Turn`s and, for the source, the `AudioSource` to play.
34
+ - **Turn** — one speech segment: a `speakerId` (pointing into the document's shared `speakers` map, or `null` if unassigned), a language, and either `text` (a plain string, e.g. from a live text-only source) or `words` (per-word timing, e.g. from ASR) — never both.
35
+ - **Word** — one word inside a turn: its text, character offsets into the turn's plain text, and optional start/end time and confidence.
36
+ - **Speaker** — id, display name, color. Stored once per document, referenced by id from every `Turn` — renaming a speaker updates every turn that points at them for free.
37
+
38
+ ```
39
+ EditorDocument
40
+ ├── speakers: Map<id, Speaker>
41
+ └── channels: Channel[]
42
+ └── translations: Translation[] (one is the source; others are translations)
43
+ └── turns: Turn[]
44
+ ├── speakerId → looked up in EditorDocument.speakers
45
+ └── text (string) OR words (Word[]) — never both
46
+ ```
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@linto-ai/transcript-ui-core",
3
+ "version": "0.9.0",
4
+ "description": "State layer and UI shell for @linto-ai/transcript-ui — core, stores, format adapters, and the TranscriptUI component",
5
+ "keywords": [
6
+ "vue",
7
+ "vue3",
8
+ "transcript",
9
+ "transcription",
10
+ "linto"
11
+ ],
12
+ "sideEffects": [
13
+ "**/*.vue",
14
+ "**/*.css"
15
+ ],
16
+ "homepage": "https://github.com/linto-ai/linto-studio",
17
+ "bugs": "https://github.com/linto-ai/linto-studio/issues",
18
+ "author": "Tom Darboux <tdarboux@linagora.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/linto-ai/linto-studio.git",
22
+ "directory": "studio-sdk/components/transcript-ui/packages/core"
23
+ },
24
+ "type": "module",
25
+ "license": "AGPL-3.0",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "main": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "exports": {
35
+ ".": "./src/index.ts",
36
+ "./test-utils": "./src/core/helpers/test/makeTestCore.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "vue": "^3.5.0"
40
+ },
41
+ "dependencies": {
42
+ "@linto-ai/transcript-ui-i18n": "0.9.0",
43
+ "@linto-ai/transcript-ui-ui": "0.9.0",
44
+ "vue-stick-to-bottom": "^1.0.0"
45
+ }
46
+ }
@@ -0,0 +1,44 @@
1
+ import type { ApiDocument } from '../types/api'
2
+ import type { EditorDocument, Speaker } from '../types/editor'
3
+ import { mapApiTurns } from './mapApiTurns'
4
+
5
+ export function mapApiDocument(raw: ApiDocument): EditorDocument {
6
+ const speakers = new Map<string, Speaker>()
7
+
8
+ for (const s of raw.speakers) {
9
+ speakers.set(s.speaker_id, {
10
+ id: s.speaker_id,
11
+ name: s.speaker_name,
12
+ color: '',
13
+ })
14
+ }
15
+
16
+ const turns = mapApiTurns(raw.text)
17
+
18
+ const sourceLanguage = raw.metadata.transcription.lang ?? raw.text[0]?.language ?? 'fr'
19
+
20
+ return {
21
+ title: raw.name,
22
+ description: raw.description,
23
+ speakers,
24
+ channels: [
25
+ {
26
+ id: 'default',
27
+ name: 'Canal 1',
28
+ duration: raw.metadata.audio.duration,
29
+ translations: [
30
+ {
31
+ id: 'source',
32
+ languages: [sourceLanguage],
33
+ isSource: true,
34
+ audio: {
35
+ src: raw.metadata.audio.filepath,
36
+ filename: raw.metadata.audio.filename,
37
+ },
38
+ turns,
39
+ },
40
+ ],
41
+ },
42
+ ],
43
+ }
44
+ }
@@ -0,0 +1,32 @@
1
+ import type { ApiTurn } from "../types/api"
2
+ import type { Turn } from "../types/editor"
3
+ import { wordsFromApi } from "../utils/turnWords"
4
+
5
+ /**
6
+ * Map backend turns (one translation's `text` array) onto editor Turns.
7
+ *
8
+ * This is where the editor's input invariants are enforced — positional word
9
+ * identity (`turnId#index`), offsets on the single-space layout shared with
10
+ * the server, silence placeholders dropped, wire wid ignored. Embedders fetch
11
+ * and assemble the document topology themselves, but the per-turn mapping
12
+ * must go through here.
13
+ */
14
+ export function mapApiTurns(apiTurns: ApiTurn[]): Turn[] {
15
+ return apiTurns.map((t) => {
16
+ const words = wordsFromApi(t.turn_id, t.words)
17
+ const startTime = words[0]?.startTime ?? t.stime
18
+ const endTime =
19
+ words.length > 0 ? (words[words.length - 1]!.endTime ?? t.etime) : t.etime
20
+
21
+ return {
22
+ id: t.turn_id,
23
+ speakerId: t.speaker_id || null,
24
+ text: words.length > 0 ? null : t.segment,
25
+ words,
26
+ ...(startTime !== undefined && { startTime }),
27
+ ...(endTime !== undefined && { endTime }),
28
+ // The ApiTurn type says required; real payloads disagree.
29
+ language: t.language ?? "",
30
+ }
31
+ })
32
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { mapApiTurns } from "../mapApiTurns"
3
+
4
+ describe("mapApiTurns", () => {
5
+ it("maps a turn with words: positional ids, offsets, turn times from words", () => {
6
+ const [turn] = mapApiTurns([
7
+ {
8
+ turn_id: "turn-1",
9
+ speaker_id: "spk-1",
10
+ segment: "Bonjour tout",
11
+ raw_segment: "Bonjour tout",
12
+ language: "fr",
13
+ words: [
14
+ { wid: "w-a", word: "Bonjour", stime: 0.5, etime: 0.8 },
15
+ { wid: "w-b", word: "tout", stime: 0.9, etime: 1.1 },
16
+ ],
17
+ },
18
+ ])
19
+ expect(turn).toMatchObject({
20
+ id: "turn-1",
21
+ speakerId: "spk-1",
22
+ text: null,
23
+ startTime: 0.5,
24
+ endTime: 1.1,
25
+ language: "fr",
26
+ })
27
+ expect(turn?.words.map((w) => w.id)).toEqual(["turn-1#0", "turn-1#1"])
28
+ expect(turn?.words[0]).toMatchObject({ charStart: 0, charEnd: 7 })
29
+ // The wire wid never reaches the editor model.
30
+ expect(turn?.words[0] && "wid" in turn.words[0]).toBe(false)
31
+ })
32
+
33
+ it("falls back to the segment and turn-level times for words-less turns", () => {
34
+ const [turn] = mapApiTurns([
35
+ {
36
+ turn_id: "turn-2",
37
+ speaker_id: "",
38
+ segment: "texte brut",
39
+ raw_segment: "texte brut",
40
+ language: "fr",
41
+ words: [],
42
+ stime: 3,
43
+ etime: 5,
44
+ },
45
+ ])
46
+ expect(turn).toMatchObject({
47
+ id: "turn-2",
48
+ speakerId: null,
49
+ text: "texte brut",
50
+ words: [],
51
+ startTime: 3,
52
+ endTime: 5,
53
+ })
54
+ })
55
+
56
+ it("drops silence placeholders (empty words) from the word list", () => {
57
+ const [turn] = mapApiTurns([
58
+ {
59
+ turn_id: "turn-3",
60
+ speaker_id: "spk-1",
61
+ segment: "mot",
62
+ raw_segment: "mot",
63
+ language: "fr",
64
+ words: [
65
+ { word: "", stime: 0, etime: 1 },
66
+ { word: "mot", stime: 1, etime: 2 },
67
+ ],
68
+ },
69
+ ])
70
+ expect(turn?.words.map((w) => w.text)).toEqual(["mot"])
71
+ expect(turn?.words[0]?.charStart).toBe(0)
72
+ })
73
+ })
@@ -0,0 +1,73 @@
1
+ import type { WhisperXDocument, WhisperXWord } from '../types/whisperx'
2
+ import type { EditorDocument, Speaker, Turn, Word } from '../types/editor'
3
+ import { layoutWords } from '../utils/turnWords'
4
+
5
+ /** Positional identity + offsets over the single-space layout the doc is
6
+ * seeded with — same convention as wordsFromApi. */
7
+ function mapWords(turnId: string, whisperWords: WhisperXWord[]): Word[] {
8
+ return layoutWords(
9
+ turnId,
10
+ whisperWords.map((w) => ({
11
+ text: w.word ?? '',
12
+ startTime: w.start,
13
+ endTime: w.end,
14
+ confidence: w.score,
15
+ })),
16
+ )
17
+ }
18
+
19
+ export function mapWhisperXDocument(raw: WhisperXDocument): EditorDocument {
20
+ const speakers = new Map<string, Speaker>()
21
+
22
+ // Collect unique speakers from segments
23
+ for (const seg of raw.segments) {
24
+ if (seg.speaker && !speakers.has(seg.speaker)) {
25
+ speakers.set(seg.speaker, {
26
+ id: seg.speaker,
27
+ name: seg.speaker,
28
+ color: '',
29
+ })
30
+ }
31
+ }
32
+
33
+ const language = raw.language ?? 'fr'
34
+
35
+ const turns: Turn[] = raw.segments.map((seg, i) => {
36
+ const words = mapWords(`turn_${i}`, seg.words)
37
+
38
+ return {
39
+ id: `turn_${i}`,
40
+ speakerId: seg.speaker ?? null,
41
+ text: words.length > 0 ? null : seg.text,
42
+ words,
43
+ startTime: seg.start,
44
+ endTime: seg.end,
45
+ language,
46
+ }
47
+ })
48
+
49
+ // Compute total duration from last segment
50
+ const duration = raw.segments.length > 0
51
+ ? raw.segments[raw.segments.length - 1]!.end
52
+ : 0
53
+
54
+ return {
55
+ title: '',
56
+ speakers,
57
+ channels: [
58
+ {
59
+ id: 'default',
60
+ name: 'Canal 1',
61
+ duration,
62
+ translations: [
63
+ {
64
+ id: 'source',
65
+ languages: [language],
66
+ isSource: true,
67
+ turns,
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ }
73
+ }
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import { FormInput } from "@linto-ai/transcript-ui-ui"
3
+ import { computed } from "vue"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+
6
+ const props = defineProps<{
7
+ channels: { id: string; name: string }[]
8
+ selectedChannelId: string
9
+ }>()
10
+
11
+ const emit = defineEmits<{
12
+ "update:selectedChannelId": [id: string]
13
+ }>()
14
+
15
+ const { t } = useI18n()
16
+
17
+ const options = computed(() =>
18
+ props.channels.map((c) => ({ value: c.id, label: c.name })),
19
+ )
20
+
21
+ const field = computed(() => ({ label: t("sidebar.channelSelectLabel") }))
22
+ </script>
23
+
24
+ <template>
25
+ <FormInput
26
+ select
27
+ :field="field"
28
+ :options="options"
29
+ :model-value="selectedChannelId"
30
+ @update:model-value="emit('update:selectedChannelId', $event)" />
31
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { EditorIcon } from "@linto-ai/transcript-ui-ui"
3
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
4
+
5
+ defineProps<{
6
+ /** Technical reason (server message), shown as a muted detail line. */
7
+ message?: string | null
8
+ }>()
9
+
10
+ const { t } = useI18n()
11
+ </script>
12
+
13
+ <template>
14
+ <div class="editor-error" role="alert">
15
+ <EditorIcon name="warning" :size="40" class="editor-error__icon" />
16
+ <p class="editor-error__title">{{ t("editor.loadError") }}</p>
17
+ <p v-if="message" class="editor-error__detail">{{ message }}</p>
18
+ </div>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .editor-error {
23
+ position: absolute;
24
+ inset: 0;
25
+ z-index: 20;
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ justify-content: center;
30
+ gap: var(--spacing-sm);
31
+ padding: var(--spacing-xl);
32
+ text-align: center;
33
+ /* Opaque so the half-built layout never shows behind. No backdrop-filter:
34
+ it is banned in this package (WebRender GTT freeze). */
35
+ background-color: var(--color-background);
36
+ font-family: var(--font-family);
37
+ }
38
+
39
+ .editor-error__icon {
40
+ color: var(--color-danger);
41
+ }
42
+
43
+ .editor-error__title {
44
+ margin: 0;
45
+ font-size: var(--font-size-lg);
46
+ font-weight: 600;
47
+ color: var(--color-text-primary);
48
+ }
49
+
50
+ .editor-error__detail {
51
+ margin: 0;
52
+ font-size: var(--font-size-sm);
53
+ color: var(--color-text-muted);
54
+ }
55
+ </style>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
3
+
4
+ const { t } = useI18n()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="editor-loading" role="status" aria-live="polite">
9
+ <span class="editor-loading__spinner" aria-hidden="true" />
10
+ <span class="editor-loading__label">{{ t("editor.loading") }}</span>
11
+ </div>
12
+ </template>
13
+
14
+ <style scoped>
15
+ .editor-loading {
16
+ position: absolute;
17
+ inset: 0;
18
+ z-index: 20;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ gap: var(--spacing-md);
24
+ /* Opaque so the half-built layout never flashes behind while collab syncs.
25
+ No backdrop-filter: it is banned in this package (WebRender GTT freeze). */
26
+ background-color: var(--color-background);
27
+ color: var(--color-text-secondary);
28
+ font-family: var(--font-family);
29
+ font-size: var(--font-size-sm);
30
+ }
31
+
32
+ .editor-loading__spinner {
33
+ width: 32px;
34
+ height: 32px;
35
+ border: 3px solid var(--color-border);
36
+ border-top-color: var(--color-primary);
37
+ border-radius: 50%;
38
+ animation: editor-loading-spin 0.8s linear infinite;
39
+ }
40
+
41
+ @keyframes editor-loading-spin {
42
+ to {
43
+ transform: rotate(360deg);
44
+ }
45
+ }
46
+
47
+ @media (prefers-reduced-motion: reduce) {
48
+ .editor-loading__spinner {
49
+ animation: none;
50
+ }
51
+ }
52
+ </style>
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ import { Button, EditorIcon } from "@linto-ai/transcript-ui-ui"
3
+ import { computed } from "vue"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+ import * as utils from "../utils"
6
+
7
+ const props = defineProps<{
8
+ title: string
9
+ date: string | number | null
10
+ duration: number
11
+ speakerCount: number
12
+ isMobile: boolean
13
+ canAsk?: boolean
14
+ }>()
15
+
16
+ defineEmits<{
17
+ toggleSidebar: []
18
+ openChat: []
19
+ }>()
20
+
21
+ const { t, locale } = useI18n()
22
+
23
+ const formattedTitle = computed(() => props.title.replace(/-/g, " "))
24
+ const formattedDate = computed(() =>
25
+ props.date != null ? utils.formatLongDate(props.date, locale.value) : "",
26
+ )
27
+ const formattedDuration = computed(() =>
28
+ utils.formatDurationMinutes(props.duration, locale.value),
29
+ )
30
+ const formattedSpeakerCount = computed(() =>
31
+ t("header.speakerCount", { count: props.speakerCount }),
32
+ )
33
+
34
+ const metaParts = computed(() =>
35
+ [
36
+ formattedDate.value,
37
+ formattedDuration.value,
38
+ formattedSpeakerCount.value,
39
+ ].filter(Boolean),
40
+ )
41
+ </script>
42
+
43
+ <template>
44
+ <header class="editor-header">
45
+ <div class="header-main">
46
+ <h1 class="document-title">{{ formattedTitle }}</h1>
47
+ <div v-if="metaParts.length" class="document-meta">
48
+ <span v-for="(part, i) in metaParts" :key="i" class="document-meta__part">
49
+ {{ part }}
50
+ </span>
51
+ </div>
52
+ </div>
53
+ <div class="header-right">
54
+ <Button
55
+ v-if="isMobile"
56
+ variant="transparent"
57
+ :aria-label="t('header.openSidebar')"
58
+ @click="$emit('toggleSidebar')">
59
+ <template #icon><EditorIcon name="users" :size="16" /></template>
60
+ </Button>
61
+ <Button
62
+ variant="primary"
63
+ :aria-label="t('header.ask')"
64
+ :disabled="!props.canAsk"
65
+ @click="$emit('openChat')">
66
+ <template #icon><EditorIcon name="sparkles" :size="16" /></template>
67
+ <span v-if="!isMobile">{{ t("header.ask") }}</span>
68
+ </Button>
69
+ </div>
70
+ </header>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .editor-header {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ gap: var(--spacing-md);
79
+ padding: var(--spacing-sm) var(--spacing-lg);
80
+ min-height: var(--header-height);
81
+ border-bottom: 1px solid var(--color-border);
82
+ background-color: var(--color-surface);
83
+ flex-shrink: 0;
84
+ }
85
+
86
+ .header-main {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 2px;
90
+ min-width: 0;
91
+ flex: 1;
92
+ }
93
+
94
+ .document-title {
95
+ font-size: var(--font-size-lg);
96
+ font-weight: 600;
97
+ color: var(--color-text-primary);
98
+ white-space: nowrap;
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
101
+ margin: 0;
102
+ }
103
+
104
+ .document-meta {
105
+ display: flex;
106
+ flex-wrap: wrap;
107
+ align-items: center;
108
+ gap: var(--spacing-xs);
109
+ font-size: var(--font-size-xs);
110
+ color: var(--color-text-muted);
111
+ }
112
+
113
+ .document-meta__part {
114
+ text-box: cap alphabetic;
115
+ }
116
+
117
+ .document-meta__part + .document-meta__part::before {
118
+ content: "·";
119
+ margin-right: var(--spacing-xs);
120
+ }
121
+
122
+ .header-right {
123
+ display: flex;
124
+ align-items: center;
125
+ gap: var(--spacing-sm);
126
+ flex-shrink: 0;
127
+ }
128
+
129
+ @media (max-width: 767px) {
130
+ .editor-header {
131
+ padding: var(--spacing-xs) var(--spacing-md);
132
+ }
133
+
134
+ .document-title {
135
+ font-size: var(--font-size-base);
136
+ }
137
+ }
138
+ </style>