@joewinke/jatui 0.1.23 → 0.1.25

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 (124) hide show
  1. package/CLAUDE.md +244 -0
  2. package/package.json +23 -10
  3. package/src/lib/actions/collapse.ts +0 -136
  4. package/src/lib/actions/railNav.ts +0 -473
  5. package/src/lib/components/AnnotationLayer.svelte +0 -108
  6. package/src/lib/components/AnnotationPanel.svelte +0 -319
  7. package/src/lib/components/AudioWaveform.svelte +0 -698
  8. package/src/lib/components/AvailabilityModal.svelte +0 -177
  9. package/src/lib/components/AvatarUpload.svelte +0 -234
  10. package/src/lib/components/Badge.svelte +0 -38
  11. package/src/lib/components/BookingForm.svelte +0 -278
  12. package/src/lib/components/BurndownChart.svelte +0 -778
  13. package/src/lib/components/Button.svelte +0 -81
  14. package/src/lib/components/CalendarPicker.svelte +0 -284
  15. package/src/lib/components/Card.svelte +0 -67
  16. package/src/lib/components/CharacterCounter.svelte +0 -82
  17. package/src/lib/components/ChipInput.svelte +0 -609
  18. package/src/lib/components/Collapse.svelte +0 -82
  19. package/src/lib/components/ColorSelector.svelte +0 -167
  20. package/src/lib/components/CommentThread.svelte +0 -773
  21. package/src/lib/components/ConfirmDialog.svelte +0 -348
  22. package/src/lib/components/ConfirmModal.svelte +0 -142
  23. package/src/lib/components/ContextMenu.svelte +0 -188
  24. package/src/lib/components/CountdownTimer.svelte +0 -94
  25. package/src/lib/components/DateRangePicker.svelte +0 -194
  26. package/src/lib/components/Drawer.svelte +0 -143
  27. package/src/lib/components/EmojiPicker.svelte +0 -159
  28. package/src/lib/components/EntityPreviewCard.svelte +0 -104
  29. package/src/lib/components/FileDropzone.svelte +0 -493
  30. package/src/lib/components/FilePicker.svelte +0 -488
  31. package/src/lib/components/FileThumbnail.svelte +0 -80
  32. package/src/lib/components/FilterDropdown.svelte +0 -203
  33. package/src/lib/components/FloatingActionBar.svelte +0 -354
  34. package/src/lib/components/GPSTracker.svelte +0 -202
  35. package/src/lib/components/HunkDiffView.svelte +0 -348
  36. package/src/lib/components/ImageLightbox.svelte +0 -274
  37. package/src/lib/components/ImageUpload.svelte +0 -147
  38. package/src/lib/components/InlineEdit.svelte +0 -291
  39. package/src/lib/components/InputDialog.svelte +0 -327
  40. package/src/lib/components/KeyboardShortcutsOverlay.svelte +0 -296
  41. package/src/lib/components/LazyImage.svelte +0 -123
  42. package/src/lib/components/LinkShortener.svelte +0 -274
  43. package/src/lib/components/LoadingSpinner.svelte +0 -106
  44. package/src/lib/components/LocationMap.svelte +0 -186
  45. package/src/lib/components/MapView.svelte +0 -341
  46. package/src/lib/components/MarkupEditor.svelte +0 -485
  47. package/src/lib/components/MarkupOverlay.svelte +0 -55
  48. package/src/lib/components/MediaWorkbench.svelte +0 -871
  49. package/src/lib/components/MilestoneCard.svelte +0 -98
  50. package/src/lib/components/MilestoneTimeline.svelte +0 -91
  51. package/src/lib/components/Modal.svelte +0 -244
  52. package/src/lib/components/PDFViewer.svelte +0 -105
  53. package/src/lib/components/PdfThumbnail.svelte +0 -63
  54. package/src/lib/components/PhoneInput.svelte +0 -212
  55. package/src/lib/components/ResizableDivider.svelte +0 -307
  56. package/src/lib/components/ResizablePanel.svelte +0 -305
  57. package/src/lib/components/SearchDropdown.svelte +0 -421
  58. package/src/lib/components/SelectInput.svelte +0 -237
  59. package/src/lib/components/SidebarUserFooter.svelte +0 -34
  60. package/src/lib/components/SignaturePad.svelte +0 -175
  61. package/src/lib/components/SmartImageEditor.svelte +0 -720
  62. package/src/lib/components/SortDropdown.svelte +0 -156
  63. package/src/lib/components/Sparkline.svelte +0 -116
  64. package/src/lib/components/SpeechForm.svelte +0 -114
  65. package/src/lib/components/StatusBadge.svelte +0 -157
  66. package/src/lib/components/TaskTypeIcon.svelte +0 -54
  67. package/src/lib/components/TextArea.svelte +0 -162
  68. package/src/lib/components/TextInput.svelte +0 -131
  69. package/src/lib/components/ThemeSelector.svelte +0 -206
  70. package/src/lib/components/TimeSlotPicker.svelte +0 -162
  71. package/src/lib/components/UserAvatar.svelte +0 -72
  72. package/src/lib/components/VariablePicker.svelte +0 -170
  73. package/src/lib/components/VoicePlayer.svelte +0 -422
  74. package/src/lib/components/linked-columns/LinkedColumns.svelte +0 -526
  75. package/src/lib/components/markup.ts +0 -287
  76. package/src/lib/components/messaging/Avatar.svelte +0 -81
  77. package/src/lib/components/messaging/ChannelInfoModal.svelte +0 -163
  78. package/src/lib/components/messaging/ChannelList.svelte +0 -107
  79. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +0 -69
  80. package/src/lib/components/messaging/ChannelMembersModal.svelte +0 -182
  81. package/src/lib/components/messaging/CreateChannelModal.svelte +0 -190
  82. package/src/lib/components/messaging/DirectMessageList.svelte +0 -145
  83. package/src/lib/components/messaging/EmojiSelector.svelte +0 -261
  84. package/src/lib/components/messaging/MentionAutocomplete.svelte +0 -193
  85. package/src/lib/components/messaging/MessageAttachment.svelte +0 -273
  86. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +0 -243
  87. package/src/lib/components/messaging/MessageInput.svelte +0 -452
  88. package/src/lib/components/messaging/MessageItem.svelte +0 -341
  89. package/src/lib/components/messaging/MessageThread.svelte +0 -306
  90. package/src/lib/components/messaging/NotificationSettingsModal.svelte +0 -234
  91. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +0 -123
  92. package/src/lib/components/messaging/StartDMModal.svelte +0 -100
  93. package/src/lib/components/messaging/ThreadPanel.svelte +0 -153
  94. package/src/lib/components/pipeline/DESIGN.md +0 -361
  95. package/src/lib/components/pipeline/Pipeline.svelte +0 -391
  96. package/src/lib/components/pipeline/PipelineCard.svelte +0 -94
  97. package/src/lib/components/pipeline/PipelineColumn.svelte +0 -163
  98. package/src/lib/components/replay/ChapterTimeline.svelte +0 -326
  99. package/src/lib/components/session-nav/transcriptModel.ts +0 -352
  100. package/src/lib/data/emojis.ts +0 -1208
  101. package/src/lib/index.ts +0 -405
  102. package/src/lib/stores/confirmDialog.svelte.ts +0 -48
  103. package/src/lib/stores/inputDialog.svelte.ts +0 -51
  104. package/src/lib/styles/rail.css +0 -63
  105. package/src/lib/types/annotation.ts +0 -38
  106. package/src/lib/types/booking.ts +0 -143
  107. package/src/lib/types/comments.ts +0 -97
  108. package/src/lib/types/entityPreview.ts +0 -45
  109. package/src/lib/types/filePicker.ts +0 -16
  110. package/src/lib/types/googleMaps.d.ts +0 -51
  111. package/src/lib/types/maps.ts +0 -43
  112. package/src/lib/types/messaging.ts +0 -459
  113. package/src/lib/types/milestone.ts +0 -22
  114. package/src/lib/types/pipeline.ts +0 -150
  115. package/src/lib/types/smartImageEditor.ts +0 -39
  116. package/src/lib/types/templateVars.ts +0 -36
  117. package/src/lib/utils/currency.ts +0 -20
  118. package/src/lib/utils/daisyuiColors.ts +0 -243
  119. package/src/lib/utils/dateFormatters.ts +0 -196
  120. package/src/lib/utils/googleMapsLoader.ts +0 -84
  121. package/src/lib/utils/mentionParser.ts +0 -188
  122. package/src/lib/utils/phone.ts +0 -80
  123. package/src/lib/utils/phoneFormat.ts +0 -74
  124. package/src/lib/utils/taskUtils.ts +0 -75
@@ -1,72 +0,0 @@
1
- <!--
2
- UserAvatar — shows a user's avatar image or initials fallback.
3
- Generic component with no DB type coupling.
4
- For use anywhere a user needs to be visually represented.
5
- -->
6
- <script lang="ts">
7
- interface Props {
8
- name?: string | null
9
- email?: string | null
10
- avatarUrl?: string | null
11
- size?: 'xs' | 'sm' | 'md' | 'lg'
12
- tooltip?: boolean
13
- class?: string
14
- }
15
-
16
- let {
17
- name = null,
18
- email = null,
19
- avatarUrl = null,
20
- size = 'sm',
21
- tooltip = true,
22
- class: className = '',
23
- }: Props = $props()
24
-
25
- const sizeClasses: Record<string, string> = {
26
- xs: 'w-5 h-5 text-[9px]',
27
- sm: 'w-7 h-7 text-xs',
28
- md: 'w-9 h-9 text-sm',
29
- lg: 'w-12 h-12 text-base',
30
- }
31
-
32
- const initials = $derived.by(() => {
33
- const source = name || email || ''
34
- if (!source) return '?'
35
- // For email, use first char before @
36
- const parts = source.includes('@')
37
- ? [source.split('@')[0]]
38
- : source.split(' ').filter(Boolean)
39
- if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
40
- return parts[0]?.charAt(0).toUpperCase() || '?'
41
- })
42
-
43
- const label = $derived(name || email || 'Unknown')
44
-
45
- // Derive a consistent color per user from their name/email
46
- const avatarBg = $derived.by(() => {
47
- const source = name || email || ''
48
- if (!source) return 'oklch(0.65 0.10 240)'
49
- let hash = 0
50
- for (let i = 0; i < source.length; i++) {
51
- hash = source.charCodeAt(i) + ((hash << 5) - hash)
52
- }
53
- const hues = [30, 85, 145, 200, 240, 270, 310, 350]
54
- const hue = hues[Math.abs(hash) % hues.length]
55
- return `oklch(0.62 0.14 ${hue})`
56
- })
57
- </script>
58
-
59
- <div
60
- class="inline-flex shrink-0 {className}"
61
- title={tooltip ? label : undefined}
62
- >
63
- <div class="rounded-full overflow-hidden {sizeClasses[size]}">
64
- {#if avatarUrl}
65
- <img src={avatarUrl} alt={label} class="w-full h-full object-cover" />
66
- {:else}
67
- <div class="w-full h-full flex items-center justify-center font-semibold text-white" style="background: {avatarBg}">
68
- {initials}
69
- </div>
70
- {/if}
71
- </div>
72
- </div>
@@ -1,170 +0,0 @@
1
- <script lang="ts">
2
- /**
3
- * VariablePicker — browse and insert template merge variables.
4
- *
5
- * Renders the available merge variables for a document template, grouped by
6
- * context (client / org / financial / custom). Clicking a variable inserts
7
- * its `{{name}}` token at the caret of a bound textarea/input and/or fires
8
- * an `onInsert` callback.
9
- *
10
- * Source of `groups`: the JST server-side registry
11
- * `getVarsForCategory(category, template.available_vars)` in
12
- * `src/lib/server/template-vars.ts`. Compute it in a `+page.server.ts` load
13
- * (the registry is server-only) and pass the result straight in.
14
- *
15
- * This is a self-contained panel (not a modal) so it can sit beside a
16
- * template editor as a sidebar. Wrap it in your own Drawer/Modal if needed.
17
- */
18
-
19
- import type { TemplateVar, TemplateVarGroup } from "../types/templateVars"
20
-
21
- interface Props {
22
- /** Variable groups to show. From `getVarsForCategory(...)`. */
23
- groups: TemplateVarGroup[]
24
- /**
25
- * Optional textarea/input to insert into. When set, clicking a variable
26
- * splices `{{name}}` at the current caret, restores focus + caret after
27
- * the token, and dispatches an `input` event so `bind:value` updates.
28
- */
29
- targetEl?: HTMLTextAreaElement | HTMLInputElement | null
30
- /** Called with the inserted token (`{{name}}`) and the bare var name. */
31
- onInsert?: (token: string, varName: string) => void
32
- /** Show the filter box. Default true. */
33
- searchable?: boolean
34
- /** Denser layout for narrow sidebars. Default false. */
35
- compact?: boolean
36
- /** Panel heading. Default "Variables". */
37
- title?: string
38
- /** Shown when there are no groups/vars at all. */
39
- emptyText?: string
40
- }
41
-
42
- let {
43
- groups,
44
- targetEl = null,
45
- onInsert,
46
- searchable = true,
47
- compact = false,
48
- title = "Variables",
49
- emptyText = "No variables available for this template.",
50
- }: Props = $props()
51
-
52
- let query = $state("")
53
-
54
- const totalVars = $derived(
55
- groups.reduce((n, g) => n + g.vars.length, 0),
56
- )
57
-
58
- // Filter vars by name/label/description, dropping groups left empty.
59
- const filteredGroups = $derived.by<TemplateVarGroup[]>(() => {
60
- const q = query.trim().toLowerCase()
61
- if (!q) return groups
62
- const out: TemplateVarGroup[] = []
63
- for (const g of groups) {
64
- const vars = g.vars.filter(
65
- (v) =>
66
- v.name.toLowerCase().includes(q) ||
67
- v.label.toLowerCase().includes(q) ||
68
- (v.description?.toLowerCase().includes(q) ?? false),
69
- )
70
- if (vars.length > 0) out.push({ ...g, vars })
71
- }
72
- return out
73
- })
74
-
75
- function tokenFor(v: TemplateVar): string {
76
- return `{{${v.name}}}`
77
- }
78
-
79
- function insert(v: TemplateVar) {
80
- const token = tokenFor(v)
81
-
82
- if (targetEl) {
83
- const el = targetEl
84
- const start = el.selectionStart ?? el.value.length
85
- const end = el.selectionEnd ?? el.value.length
86
- el.value = el.value.slice(0, start) + token + el.value.slice(end)
87
- const caret = start + token.length
88
- // Restore focus + caret after the inserted token.
89
- el.focus()
90
- el.setSelectionRange(caret, caret)
91
- // Notify Svelte bindings / listeners.
92
- el.dispatchEvent(new Event("input", { bubbles: true }))
93
- }
94
-
95
- onInsert?.(token, v.name)
96
- }
97
- </script>
98
-
99
- <div
100
- class="border-base-300 bg-base-100 flex flex-col rounded-box border"
101
- class:text-sm={compact}
102
- >
103
- <div class="border-base-300 flex items-center justify-between border-b px-4 py-3">
104
- <h3 class="font-semibold">{title}</h3>
105
- {#if totalVars > 0}
106
- <span class="badge badge-ghost badge-sm">{totalVars}</span>
107
- {/if}
108
- </div>
109
-
110
- {#if searchable && totalVars > 0}
111
- <div class="border-base-300 border-b p-3">
112
- <input
113
- type="text"
114
- class="input input-bordered input-sm w-full"
115
- placeholder="Filter variables…"
116
- aria-label="Filter variables"
117
- bind:value={query}
118
- />
119
- </div>
120
- {/if}
121
-
122
- <div class="flex-1 overflow-y-auto p-2" class:max-h-96={!compact}>
123
- {#if totalVars === 0}
124
- <p class="text-base-content/60 px-2 py-6 text-center text-sm">
125
- {emptyText}
126
- </p>
127
- {:else if filteredGroups.length === 0}
128
- <p class="text-base-content/60 px-2 py-6 text-center text-sm">
129
- No variables match "{query}".
130
- </p>
131
- {:else}
132
- {#each filteredGroups as group (group.id)}
133
- <div class="mb-3 last:mb-0">
134
- <div
135
- class="text-base-content/45 px-2 pb-1 text-[0.8125rem] tracking-[0.005em]"
136
- >
137
- {group.label}
138
- </div>
139
- <div class="flex flex-col gap-0.5">
140
- {#each group.vars as v (v.name)}
141
- <button
142
- type="button"
143
- class="hover:bg-base-200 focus-visible:bg-base-200 group flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors"
144
- onclick={() => insert(v)}
145
- title={`Insert ${tokenFor(v)}`}
146
- >
147
- <div class="flex w-full items-baseline gap-2">
148
- <span class="font-medium flex-1 min-w-0 truncate">{v.label}</span>
149
- <code
150
- class="text-base-content/60 group-hover:text-primary shrink-0 font-mono text-xs"
151
- >
152
- {tokenFor(v)}
153
- </code>
154
- </div>
155
- {#if v.description && !compact}
156
- <span class="text-base-content/45 text-[0.75rem]">
157
- {v.description}{#if v.example}
158
- <span class="text-base-content/45">
159
- — e.g. {v.example}</span
160
- >{/if}
161
- </span>
162
- {/if}
163
- </button>
164
- {/each}
165
- </div>
166
- </div>
167
- {/each}
168
- {/if}
169
- </div>
170
- </div>
@@ -1,422 +0,0 @@
1
- <script lang="ts">
2
- /**
3
- * VoicePlayer — TTS playback widget with voice selection, volume, speed, and provider switching.
4
- *
5
- * Requires backend endpoints:
6
- * - voicesUrl (GET) — returns { voices: [{ voice_id, name, labels?, preview_url? }] }
7
- * - ttsUrl (POST) — accepts { text, voiceId, provider, speed } and returns audio/mpeg blob
8
- */
9
-
10
- let {
11
- text,
12
- voiceId,
13
- voiceName,
14
- onVoiceChange,
15
- expandDirection = "left",
16
- autoPlay = false,
17
- onPlaybackComplete,
18
- onError,
19
- // External control
20
- messageId,
21
- onRegisterController,
22
- onStateChange,
23
- // API configuration
24
- voicesUrl = "/api/voices",
25
- ttsUrl = "/api/tts",
26
- } = $props<{
27
- text?: string
28
- voiceId?: string
29
- voiceName?: string
30
- onVoiceChange?: (voiceId: string, voiceName: string, provider: string) => void
31
- expandDirection?: "left" | "right"
32
- autoPlay?: boolean
33
- onPlaybackComplete?: () => void
34
- onError?: (message: string) => void
35
- messageId?: string
36
- onRegisterController?: (messageId: string, controller: any) => void
37
- onStateChange?: (playing: boolean, loading: boolean) => void
38
- voicesUrl?: string
39
- ttsUrl?: string
40
- }>()
41
-
42
- let audioEl = $state<HTMLAudioElement | null>(null)
43
- let isPlaying = $state(false)
44
- let isPaused = $state(false)
45
- let isLoadingAudio = $state(false)
46
- let audioUrl = $state<string | null>(null)
47
-
48
- let showControls = $state(false)
49
- let showSettings = $state(false)
50
-
51
- let provider = $state<"openai" | "elevenlabs">("openai")
52
-
53
- let voices = $state<any[]>([])
54
- let voicesLoading = $state(false)
55
- let showVoices = $state(false)
56
- // svelte-ignore state_referenced_locally
57
- let selectedVoiceId = $state<string | undefined>(voiceId || "alloy")
58
-
59
- let speed = $state(1.0)
60
-
61
- let autoPlayAttempted = $state(false)
62
- let manuallyPaused = $state(false)
63
-
64
- const controller = {
65
- play: () => playTextAudio(),
66
- pause: () => pauseTextAudio(),
67
- isPlaying: () => isPlaying,
68
- isLoading: () => isLoadingAudio
69
- }
70
-
71
- function reportError(msg: string) {
72
- if (onError) onError(msg)
73
- else console.error(msg)
74
- }
75
-
76
- $effect(() => {
77
- if (voiceId && voiceId !== selectedVoiceId) {
78
- selectedVoiceId = voiceId
79
- }
80
- })
81
-
82
- async function fetchVoices() {
83
- voicesLoading = true
84
- try {
85
- const res = await fetch(`${voicesUrl}?provider=${provider}`)
86
- if (!res.ok) throw new Error("Failed to fetch voices")
87
- const data = await res.json()
88
- voices = data.voices || []
89
- } catch (e: any) {
90
- reportError("Failed to load voices: " + (e.message || e))
91
- } finally {
92
- voicesLoading = false
93
- }
94
- }
95
-
96
- function switchProvider(newProvider: "openai" | "elevenlabs") {
97
- provider = newProvider
98
- selectedVoiceId = newProvider === "openai" ? "alloy" : undefined
99
- if (voices.length === 0 || provider !== newProvider) {
100
- fetchVoices()
101
- }
102
- showSettings = false
103
- }
104
-
105
- async function playTextAudio() {
106
- try {
107
- manuallyPaused = false
108
-
109
- if (audioEl && isPaused) {
110
- audioEl.play()
111
- isPlaying = true
112
- isPaused = false
113
- onStateChange?.(true, false)
114
- return
115
- }
116
- if (isPlaying || isLoadingAudio) return
117
-
118
- if (!text || !text.trim()) {
119
- reportError("No text to play")
120
- return
121
- }
122
-
123
- if (audioEl) {
124
- audioEl.pause()
125
- audioEl = null
126
- audioUrl && URL.revokeObjectURL(audioUrl)
127
- audioUrl = null
128
- }
129
-
130
- isLoadingAudio = true
131
- onStateChange?.(false, true)
132
-
133
- const res = await fetch(ttsUrl, {
134
- method: "POST",
135
- headers: { "Content-Type": "application/json" },
136
- body: JSON.stringify({ text, voiceId: selectedVoiceId, provider, speed }),
137
- })
138
-
139
- if (!res.ok) {
140
- isLoadingAudio = false
141
- onStateChange?.(false, false)
142
- const err = await res.json().catch(() => ({}))
143
- reportError("TTS error: " + (err.error || res.statusText))
144
- return
145
- }
146
-
147
- const audioData = await res.arrayBuffer()
148
- audioUrl = URL.createObjectURL(new Blob([audioData], { type: "audio/mpeg" }))
149
- audioEl = new Audio(audioUrl)
150
-
151
- audioEl.onended = () => {
152
- isPlaying = false
153
- isPaused = false
154
- isLoadingAudio = false
155
- onStateChange?.(false, false)
156
- audioEl = null
157
- audioUrl && URL.revokeObjectURL(audioUrl)
158
- audioUrl = null
159
- onPlaybackComplete?.()
160
- }
161
- audioEl.onpause = () => {
162
- if (audioEl && !audioEl.ended) {
163
- isPlaying = false
164
- isPaused = true
165
- onStateChange?.(false, false)
166
- }
167
- }
168
- audioEl.onplay = () => {
169
- isPlaying = true
170
- isPaused = false
171
- isLoadingAudio = false
172
- onStateChange?.(true, false)
173
- }
174
-
175
- await audioEl.play()
176
- } catch (e: any) {
177
- reportError("Failed to play audio: " + (e.message || e))
178
- isPlaying = false
179
- isPaused = false
180
- isLoadingAudio = false
181
- onStateChange?.(false, false)
182
- audioEl = null
183
- if (audioUrl) {
184
- URL.revokeObjectURL(audioUrl)
185
- audioUrl = null
186
- }
187
- }
188
- }
189
-
190
- function pauseTextAudio() {
191
- if (audioEl && isPlaying) {
192
- manuallyPaused = true
193
- audioEl.pause()
194
- }
195
- }
196
-
197
- function toggleVoices() {
198
- showVoices = !showVoices
199
- if (showVoices && voices.length === 0 && !voicesLoading) {
200
- fetchVoices()
201
- }
202
- }
203
-
204
- function toggleSettings() {
205
- showSettings = !showSettings
206
- }
207
-
208
- function selectVoice(id: string, name: string) {
209
- selectedVoiceId = id
210
- showVoices = false
211
- if (onVoiceChange) onVoiceChange(id, name, provider)
212
- }
213
-
214
- $effect(() => {
215
- if (messageId && onRegisterController && autoPlay) {
216
- onRegisterController(messageId, controller)
217
- }
218
- })
219
-
220
- $effect(() => {
221
- if (text) {
222
- autoPlayAttempted = false
223
- manuallyPaused = false
224
- }
225
- })
226
-
227
- $effect(() => {
228
- if (autoPlay && text?.trim() && !isPlaying && !isLoadingAudio && !autoPlayAttempted && !manuallyPaused) {
229
- autoPlayAttempted = true
230
- setTimeout(() => playTextAudio(), 100)
231
- }
232
- })
233
- </script>
234
-
235
- {#if text && text.trim()}
236
- <div class="flex items-center gap-1 justify-end">
237
- <!-- svelte-ignore a11y_no_static_element_interactions -->
238
- <div
239
- class="relative"
240
- onmouseenter={() => (showControls = true)}
241
- onmouseleave={() => (showControls = false)}
242
- onfocus={() => (showControls = true)}
243
- onblur={() => (showControls = false)}
244
- >
245
- {#if !showControls}
246
- <button
247
- class="badge badge-ghost p-2 rounded-full flex items-center justify-center"
248
- aria-label="Audio controls"
249
- onclick={() => (showControls = !showControls)}
250
- style="min-width: 32px; min-height: 32px;"
251
- >
252
- <!-- Volume2 icon -->
253
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
254
- </button>
255
- {/if}
256
- {#if showControls}
257
- <div
258
- class="absolute flex items-center gap-2 bg-base-200 border border-base-300 rounded-full z-50 transition-all {expandDirection === 'right' ? 'right-0' : 'left-0'}"
259
- style="min-width: 180px;"
260
- >
261
- <!-- Play/Pause -->
262
- <button
263
- class="btn btn-xs btn-ghost flex items-center justify-center"
264
- title={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
265
- aria-label={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
266
- onclick={isLoadingAudio ? undefined : isPlaying && !isPaused ? pauseTextAudio : playTextAudio}
267
- disabled={isLoadingAudio}
268
- tabindex="-1"
269
- style="min-width: 28px; min-height: 28px;"
270
- >
271
- {#if isLoadingAudio}
272
- <span class="loading loading-dots loading-xs"></span>
273
- {:else if isPlaying && !isPaused}
274
- <!-- PauseCircle -->
275
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="10" x2="10" y1="15" y2="9"/><line x1="14" x2="14" y1="15" y2="9"/></svg>
276
- {:else}
277
- <!-- PlayCircle -->
278
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
279
- {/if}
280
- </button>
281
-
282
- <!-- Volume slider -->
283
- <input
284
- type="range"
285
- min="0"
286
- max="1"
287
- step="0.01"
288
- value={audioEl ? audioEl.volume : 1}
289
- oninput={(e) => {
290
- const target = e.target as HTMLInputElement
291
- if (audioEl && target) audioEl.volume = +target.value
292
- }}
293
- aria-label="Volume"
294
- class="range range-xs w-16"
295
- tabindex="-1"
296
- style="min-width: 48px;"
297
- />
298
-
299
- <!-- Settings -->
300
- <button
301
- class="btn btn-xs btn-ghost flex items-center justify-center"
302
- title="TTS Settings"
303
- aria-label="TTS Settings"
304
- onclick={(e) => { e.stopPropagation(); toggleSettings() }}
305
- tabindex="-1"
306
- style="min-width: 28px; min-height: 28px;"
307
- >
308
- <!-- Settings icon -->
309
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
310
- </button>
311
-
312
- <!-- Voice Dropdown -->
313
- <div class="relative">
314
- <button
315
- class="btn btn-xs btn-ghost flex items-center justify-between border border-base-300 rounded-full px-2 py-1 bg-base-200 hover:bg-base-300 transition-colors w-24"
316
- title="Select voice"
317
- aria-label="Select voice"
318
- onclick={toggleVoices}
319
- disabled={voicesLoading}
320
- style="min-width: 64px;"
321
- tabindex="-1"
322
- >
323
- <span class="text-xs font-semibold truncate">
324
- {voicesLoading ? "..." : voiceName ? voiceName : "Voice"}
325
- </span>
326
- <span class="flex-1"></span>
327
- <!-- ChevronDown -->
328
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70"><path d="m6 9 6 6 6-6"/></svg>
329
- </button>
330
-
331
- <!-- Settings panel -->
332
- {#if showSettings}
333
- <div
334
- class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-3 w-64"
335
- style="margin-top: 0.25rem;"
336
- >
337
- <div class="mb-3">
338
- <h3 class="font-semibold mb-2">TTS Provider</h3>
339
- <div class="flex gap-2">
340
- <button class={`btn btn-xs ${provider === "openai" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("openai")}>OpenAI</button>
341
- <button class={`btn btn-xs ${provider === "elevenlabs" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("elevenlabs")}>ElevenLabs</button>
342
- </div>
343
- </div>
344
-
345
- {#if provider === "openai"}
346
- <div>
347
- <h3 class="font-semibold mb-1">Speed</h3>
348
- <div class="flex items-center gap-2">
349
- <input
350
- type="range"
351
- min="0.25"
352
- max="4.0"
353
- step="0.05"
354
- value={speed}
355
- oninput={(e) => { speed = +(e.target as HTMLInputElement).value }}
356
- class="range range-xs flex-1"
357
- />
358
- <span class="text-xs w-8 text-right">{speed.toFixed(2)}x</span>
359
- </div>
360
- </div>
361
- {/if}
362
- </div>
363
- {/if}
364
-
365
- <!-- Voice selection dropdown -->
366
- {#if showVoices}
367
- <div
368
- class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-2 max-h-64 overflow-y-auto w-64"
369
- style="margin-top: 0.25rem;"
370
- >
371
- {#if voicesLoading}
372
- <div class="flex items-center gap-2 p-2">
373
- <span class="loading loading-dots loading-xs"></span>
374
- <span>Loading voices...</span>
375
- </div>
376
- {:else if voices.length === 0}
377
- <div class="p-2 text-xs text-base-content/60">No voices found</div>
378
- {:else}
379
- <ul>
380
- {#each voices as voice}
381
- <button
382
- class="w-full text-left p-2 hover:bg-base-300 rounded-lg cursor-pointer transition-colors"
383
- onclick={() => selectVoice(voice.voice_id, voice.name)}
384
- >
385
- <div class="mb-1">
386
- <span class="font-semibold text-base-content truncate block">{voice.name}</span>
387
- {#if voice.labels}
388
- <span class="text-xs text-base-content/70 truncate block">
389
- {[voice.labels.accent, voice.labels.gender, voice.labels.age, voice.labels.description, voice.labels.use_case]
390
- .filter(Boolean)
391
- .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
392
- .join(", ")}
393
- </span>
394
- {/if}
395
- </div>
396
- <div class="flex items-center gap-1">
397
- {#if voice.preview_url}
398
- <audio
399
- src={voice.preview_url}
400
- controls
401
- style="height:1.5em;"
402
- preload="none"
403
- onclick={(e) => e.stopPropagation()}
404
- aria-label="Preview {voice.name} voice"
405
- ></audio>
406
- {/if}
407
- {#if selectedVoiceId === voice.voice_id}
408
- <span class="badge badge-primary badge-xs ml-2">Selected</span>
409
- {/if}
410
- </div>
411
- </button>
412
- {/each}
413
- </ul>
414
- {/if}
415
- </div>
416
- {/if}
417
- </div>
418
- </div>
419
- {/if}
420
- </div>
421
- </div>
422
- {/if}