@joewinke/jatui 0.1.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.
- package/package.json +46 -0
- package/src/lib/components/AudioWaveform.svelte +694 -0
- package/src/lib/components/AvailabilityModal.svelte +173 -0
- package/src/lib/components/Badge.svelte +38 -0
- package/src/lib/components/BookingForm.svelte +276 -0
- package/src/lib/components/Button.svelte +72 -0
- package/src/lib/components/CalendarPicker.svelte +284 -0
- package/src/lib/components/Card.svelte +67 -0
- package/src/lib/components/CharacterCounter.svelte +82 -0
- package/src/lib/components/ChipInput.svelte +596 -0
- package/src/lib/components/ColorSelector.svelte +163 -0
- package/src/lib/components/ConfirmModal.svelte +75 -0
- package/src/lib/components/CountdownTimer.svelte +94 -0
- package/src/lib/components/DateRangePicker.svelte +192 -0
- package/src/lib/components/Drawer.svelte +110 -0
- package/src/lib/components/FilterDropdown.svelte +202 -0
- package/src/lib/components/ImageUpload.svelte +97 -0
- package/src/lib/components/InlineEdit.svelte +283 -0
- package/src/lib/components/LazyImage.svelte +122 -0
- package/src/lib/components/LoadingSpinner.svelte +102 -0
- package/src/lib/components/Modal.svelte +208 -0
- package/src/lib/components/PhoneInput.svelte +92 -0
- package/src/lib/components/ResizableDivider.svelte +305 -0
- package/src/lib/components/ResizablePanel.svelte +302 -0
- package/src/lib/components/SearchDropdown.svelte +341 -0
- package/src/lib/components/SelectInput.svelte +215 -0
- package/src/lib/components/SignaturePad.svelte +171 -0
- package/src/lib/components/SortDropdown.svelte +148 -0
- package/src/lib/components/Sparkline.svelte +107 -0
- package/src/lib/components/SpeechForm.svelte +114 -0
- package/src/lib/components/StatusBadge.svelte +155 -0
- package/src/lib/components/TextArea.svelte +143 -0
- package/src/lib/components/TextInput.svelte +108 -0
- package/src/lib/components/ThemeSelector.svelte +195 -0
- package/src/lib/components/TimeSlotPicker.svelte +162 -0
- package/src/lib/components/VoicePlayer.svelte +420 -0
- package/src/lib/components/messaging/Avatar.svelte +81 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
- package/src/lib/components/messaging/ChannelList.svelte +107 -0
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
- package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
- package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
- package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
- package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
- package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
- package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
- package/src/lib/components/messaging/MessageInput.svelte +451 -0
- package/src/lib/components/messaging/MessageItem.svelte +338 -0
- package/src/lib/components/messaging/MessageThread.svelte +306 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
- package/src/lib/components/messaging/StartDMModal.svelte +100 -0
- package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
- package/src/lib/index.ts +185 -0
- package/src/lib/types/booking.ts +143 -0
- package/src/lib/types/messaging.ts +459 -0
- package/src/lib/utils/currency.ts +20 -0
- package/src/lib/utils/daisyuiColors.ts +243 -0
- package/src/lib/utils/dateFormatters.ts +153 -0
- package/src/lib/utils/mentionParser.ts +188 -0
- package/src/lib/utils/phoneFormat.ts +74 -0
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
let selectedVoiceId = $state<string | undefined>(voiceId || "alloy")
|
|
57
|
+
|
|
58
|
+
let speed = $state(1.0)
|
|
59
|
+
|
|
60
|
+
let autoPlayAttempted = $state(false)
|
|
61
|
+
let manuallyPaused = $state(false)
|
|
62
|
+
|
|
63
|
+
const controller = {
|
|
64
|
+
play: () => playTextAudio(),
|
|
65
|
+
pause: () => pauseTextAudio(),
|
|
66
|
+
isPlaying: () => isPlaying,
|
|
67
|
+
isLoading: () => isLoadingAudio
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reportError(msg: string) {
|
|
71
|
+
if (onError) onError(msg)
|
|
72
|
+
else console.error(msg)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (voiceId && voiceId !== selectedVoiceId) {
|
|
77
|
+
selectedVoiceId = voiceId
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
async function fetchVoices() {
|
|
82
|
+
voicesLoading = true
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${voicesUrl}?provider=${provider}`)
|
|
85
|
+
if (!res.ok) throw new Error("Failed to fetch voices")
|
|
86
|
+
const data = await res.json()
|
|
87
|
+
voices = data.voices || []
|
|
88
|
+
} catch (e: any) {
|
|
89
|
+
reportError("Failed to load voices: " + (e.message || e))
|
|
90
|
+
} finally {
|
|
91
|
+
voicesLoading = false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function switchProvider(newProvider: "openai" | "elevenlabs") {
|
|
96
|
+
provider = newProvider
|
|
97
|
+
selectedVoiceId = newProvider === "openai" ? "alloy" : undefined
|
|
98
|
+
if (voices.length === 0 || provider !== newProvider) {
|
|
99
|
+
fetchVoices()
|
|
100
|
+
}
|
|
101
|
+
showSettings = false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function playTextAudio() {
|
|
105
|
+
try {
|
|
106
|
+
manuallyPaused = false
|
|
107
|
+
|
|
108
|
+
if (audioEl && isPaused) {
|
|
109
|
+
audioEl.play()
|
|
110
|
+
isPlaying = true
|
|
111
|
+
isPaused = false
|
|
112
|
+
onStateChange?.(true, false)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (isPlaying || isLoadingAudio) return
|
|
116
|
+
|
|
117
|
+
if (!text || !text.trim()) {
|
|
118
|
+
reportError("No text to play")
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (audioEl) {
|
|
123
|
+
audioEl.pause()
|
|
124
|
+
audioEl = null
|
|
125
|
+
audioUrl && URL.revokeObjectURL(audioUrl)
|
|
126
|
+
audioUrl = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
isLoadingAudio = true
|
|
130
|
+
onStateChange?.(false, true)
|
|
131
|
+
|
|
132
|
+
const res = await fetch(ttsUrl, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ text, voiceId: selectedVoiceId, provider, speed }),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
isLoadingAudio = false
|
|
140
|
+
onStateChange?.(false, false)
|
|
141
|
+
const err = await res.json().catch(() => ({}))
|
|
142
|
+
reportError("TTS error: " + (err.error || res.statusText))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const audioData = await res.arrayBuffer()
|
|
147
|
+
audioUrl = URL.createObjectURL(new Blob([audioData], { type: "audio/mpeg" }))
|
|
148
|
+
audioEl = new Audio(audioUrl)
|
|
149
|
+
|
|
150
|
+
audioEl.onended = () => {
|
|
151
|
+
isPlaying = false
|
|
152
|
+
isPaused = false
|
|
153
|
+
isLoadingAudio = false
|
|
154
|
+
onStateChange?.(false, false)
|
|
155
|
+
audioEl = null
|
|
156
|
+
audioUrl && URL.revokeObjectURL(audioUrl)
|
|
157
|
+
audioUrl = null
|
|
158
|
+
onPlaybackComplete?.()
|
|
159
|
+
}
|
|
160
|
+
audioEl.onpause = () => {
|
|
161
|
+
if (audioEl && !audioEl.ended) {
|
|
162
|
+
isPlaying = false
|
|
163
|
+
isPaused = true
|
|
164
|
+
onStateChange?.(false, false)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
audioEl.onplay = () => {
|
|
168
|
+
isPlaying = true
|
|
169
|
+
isPaused = false
|
|
170
|
+
isLoadingAudio = false
|
|
171
|
+
onStateChange?.(true, false)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await audioEl.play()
|
|
175
|
+
} catch (e: any) {
|
|
176
|
+
reportError("Failed to play audio: " + (e.message || e))
|
|
177
|
+
isPlaying = false
|
|
178
|
+
isPaused = false
|
|
179
|
+
isLoadingAudio = false
|
|
180
|
+
onStateChange?.(false, false)
|
|
181
|
+
audioEl = null
|
|
182
|
+
if (audioUrl) {
|
|
183
|
+
URL.revokeObjectURL(audioUrl)
|
|
184
|
+
audioUrl = null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pauseTextAudio() {
|
|
190
|
+
if (audioEl && isPlaying) {
|
|
191
|
+
manuallyPaused = true
|
|
192
|
+
audioEl.pause()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toggleVoices() {
|
|
197
|
+
showVoices = !showVoices
|
|
198
|
+
if (showVoices && voices.length === 0 && !voicesLoading) {
|
|
199
|
+
fetchVoices()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function toggleSettings() {
|
|
204
|
+
showSettings = !showSettings
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function selectVoice(id: string, name: string) {
|
|
208
|
+
selectedVoiceId = id
|
|
209
|
+
showVoices = false
|
|
210
|
+
if (onVoiceChange) onVoiceChange(id, name, provider)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
$effect(() => {
|
|
214
|
+
if (messageId && onRegisterController && autoPlay) {
|
|
215
|
+
onRegisterController(messageId, controller)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
$effect(() => {
|
|
220
|
+
if (text) {
|
|
221
|
+
autoPlayAttempted = false
|
|
222
|
+
manuallyPaused = false
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
$effect(() => {
|
|
227
|
+
if (autoPlay && text?.trim() && !isPlaying && !isLoadingAudio && !autoPlayAttempted && !manuallyPaused) {
|
|
228
|
+
autoPlayAttempted = true
|
|
229
|
+
setTimeout(() => playTextAudio(), 100)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
</script>
|
|
233
|
+
|
|
234
|
+
{#if text && text.trim()}
|
|
235
|
+
<div class="flex items-center gap-1 justify-end">
|
|
236
|
+
<div
|
|
237
|
+
class="relative"
|
|
238
|
+
onmouseenter={() => (showControls = true)}
|
|
239
|
+
onmouseleave={() => (showControls = false)}
|
|
240
|
+
onfocus={() => (showControls = true)}
|
|
241
|
+
onblur={() => (showControls = false)}
|
|
242
|
+
>
|
|
243
|
+
{#if !showControls}
|
|
244
|
+
<button
|
|
245
|
+
class="badge badge-ghost p-2 rounded-full flex items-center justify-center"
|
|
246
|
+
aria-label="Audio controls"
|
|
247
|
+
onclick={() => (showControls = !showControls)}
|
|
248
|
+
style="min-width: 32px; min-height: 32px;"
|
|
249
|
+
>
|
|
250
|
+
<!-- Volume2 icon -->
|
|
251
|
+
<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>
|
|
252
|
+
</button>
|
|
253
|
+
{/if}
|
|
254
|
+
{#if showControls}
|
|
255
|
+
<div
|
|
256
|
+
class="absolute flex items-center gap-2 bg-base-200 border border-base-300 rounded-full shadow-lg z-50 transition-all {expandDirection === 'right' ? 'right-0' : 'left-0'}"
|
|
257
|
+
style="min-width: 180px;"
|
|
258
|
+
>
|
|
259
|
+
<!-- Play/Pause -->
|
|
260
|
+
<button
|
|
261
|
+
class="btn btn-xs btn-ghost flex items-center justify-center"
|
|
262
|
+
title={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
|
|
263
|
+
aria-label={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
|
|
264
|
+
onclick={isLoadingAudio ? undefined : isPlaying && !isPaused ? pauseTextAudio : playTextAudio}
|
|
265
|
+
disabled={isLoadingAudio}
|
|
266
|
+
tabindex="-1"
|
|
267
|
+
style="min-width: 28px; min-height: 28px;"
|
|
268
|
+
>
|
|
269
|
+
{#if isLoadingAudio}
|
|
270
|
+
<span class="loading loading-dots loading-xs"></span>
|
|
271
|
+
{:else if isPlaying && !isPaused}
|
|
272
|
+
<!-- PauseCircle -->
|
|
273
|
+
<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>
|
|
274
|
+
{:else}
|
|
275
|
+
<!-- PlayCircle -->
|
|
276
|
+
<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>
|
|
277
|
+
{/if}
|
|
278
|
+
</button>
|
|
279
|
+
|
|
280
|
+
<!-- Volume slider -->
|
|
281
|
+
<input
|
|
282
|
+
type="range"
|
|
283
|
+
min="0"
|
|
284
|
+
max="1"
|
|
285
|
+
step="0.01"
|
|
286
|
+
value={audioEl ? audioEl.volume : 1}
|
|
287
|
+
oninput={(e) => {
|
|
288
|
+
const target = e.target as HTMLInputElement
|
|
289
|
+
if (audioEl && target) audioEl.volume = +target.value
|
|
290
|
+
}}
|
|
291
|
+
aria-label="Volume"
|
|
292
|
+
class="range range-xs w-16"
|
|
293
|
+
tabindex="-1"
|
|
294
|
+
style="min-width: 48px;"
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
<!-- Settings -->
|
|
298
|
+
<button
|
|
299
|
+
class="btn btn-xs btn-ghost flex items-center justify-center"
|
|
300
|
+
title="TTS Settings"
|
|
301
|
+
aria-label="TTS Settings"
|
|
302
|
+
onclick={(e) => { e.stopPropagation(); toggleSettings() }}
|
|
303
|
+
tabindex="-1"
|
|
304
|
+
style="min-width: 28px; min-height: 28px;"
|
|
305
|
+
>
|
|
306
|
+
<!-- Settings icon -->
|
|
307
|
+
<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>
|
|
308
|
+
</button>
|
|
309
|
+
|
|
310
|
+
<!-- Voice Dropdown -->
|
|
311
|
+
<div class="relative">
|
|
312
|
+
<button
|
|
313
|
+
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"
|
|
314
|
+
title="Select voice"
|
|
315
|
+
aria-label="Select voice"
|
|
316
|
+
onclick={toggleVoices}
|
|
317
|
+
disabled={voicesLoading}
|
|
318
|
+
style="min-width: 64px;"
|
|
319
|
+
tabindex="-1"
|
|
320
|
+
>
|
|
321
|
+
<span class="text-xs font-semibold truncate">
|
|
322
|
+
{voicesLoading ? "..." : voiceName ? voiceName : "Voice"}
|
|
323
|
+
</span>
|
|
324
|
+
<span class="flex-1"></span>
|
|
325
|
+
<!-- ChevronDown -->
|
|
326
|
+
<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>
|
|
327
|
+
</button>
|
|
328
|
+
|
|
329
|
+
<!-- Settings panel -->
|
|
330
|
+
{#if showSettings}
|
|
331
|
+
<div
|
|
332
|
+
class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg shadow-lg p-3 w-64"
|
|
333
|
+
style="margin-top: 0.25rem;"
|
|
334
|
+
>
|
|
335
|
+
<div class="mb-3">
|
|
336
|
+
<h3 class="font-semibold mb-2">TTS Provider</h3>
|
|
337
|
+
<div class="flex gap-2">
|
|
338
|
+
<button class={`btn btn-xs ${provider === "openai" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("openai")}>OpenAI</button>
|
|
339
|
+
<button class={`btn btn-xs ${provider === "elevenlabs" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("elevenlabs")}>ElevenLabs</button>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{#if provider === "openai"}
|
|
344
|
+
<div>
|
|
345
|
+
<h3 class="font-semibold mb-1">Speed</h3>
|
|
346
|
+
<div class="flex items-center gap-2">
|
|
347
|
+
<input
|
|
348
|
+
type="range"
|
|
349
|
+
min="0.25"
|
|
350
|
+
max="4.0"
|
|
351
|
+
step="0.05"
|
|
352
|
+
value={speed}
|
|
353
|
+
oninput={(e) => { speed = +(e.target as HTMLInputElement).value }}
|
|
354
|
+
class="range range-xs flex-1"
|
|
355
|
+
/>
|
|
356
|
+
<span class="text-xs w-8 text-right">{speed.toFixed(2)}x</span>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
{/if}
|
|
360
|
+
</div>
|
|
361
|
+
{/if}
|
|
362
|
+
|
|
363
|
+
<!-- Voice selection dropdown -->
|
|
364
|
+
{#if showVoices}
|
|
365
|
+
<div
|
|
366
|
+
class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg shadow-lg p-2 max-h-64 overflow-y-auto w-64"
|
|
367
|
+
style="margin-top: 0.25rem;"
|
|
368
|
+
>
|
|
369
|
+
{#if voicesLoading}
|
|
370
|
+
<div class="flex items-center gap-2 p-2">
|
|
371
|
+
<span class="loading loading-dots loading-xs"></span>
|
|
372
|
+
<span>Loading voices...</span>
|
|
373
|
+
</div>
|
|
374
|
+
{:else if voices.length === 0}
|
|
375
|
+
<div class="p-2 text-xs text-base-content/60">No voices found</div>
|
|
376
|
+
{:else}
|
|
377
|
+
<ul>
|
|
378
|
+
{#each voices as voice}
|
|
379
|
+
<button
|
|
380
|
+
class="w-full text-left p-2 hover:bg-base-300 rounded-lg cursor-pointer transition-colors"
|
|
381
|
+
onclick={() => selectVoice(voice.voice_id, voice.name)}
|
|
382
|
+
>
|
|
383
|
+
<div class="mb-1">
|
|
384
|
+
<span class="font-semibold text-base-content truncate block">{voice.name}</span>
|
|
385
|
+
{#if voice.labels}
|
|
386
|
+
<span class="text-xs text-base-content/70 truncate block">
|
|
387
|
+
{[voice.labels.accent, voice.labels.gender, voice.labels.age, voice.labels.description, voice.labels.use_case]
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
390
|
+
.join(", ")}
|
|
391
|
+
</span>
|
|
392
|
+
{/if}
|
|
393
|
+
</div>
|
|
394
|
+
<div class="flex items-center gap-1">
|
|
395
|
+
{#if voice.preview_url}
|
|
396
|
+
<audio
|
|
397
|
+
src={voice.preview_url}
|
|
398
|
+
controls
|
|
399
|
+
style="height:1.5em;"
|
|
400
|
+
preload="none"
|
|
401
|
+
onclick={(e) => e.stopPropagation()}
|
|
402
|
+
aria-label="Preview {voice.name} voice"
|
|
403
|
+
></audio>
|
|
404
|
+
{/if}
|
|
405
|
+
{#if selectedVoiceId === voice.voice_id}
|
|
406
|
+
<span class="badge badge-primary badge-xs ml-2">Selected</span>
|
|
407
|
+
{/if}
|
|
408
|
+
</div>
|
|
409
|
+
</button>
|
|
410
|
+
{/each}
|
|
411
|
+
</ul>
|
|
412
|
+
{/if}
|
|
413
|
+
</div>
|
|
414
|
+
{/if}
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
{/if}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
{/if}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Simple Avatar Component for Messaging
|
|
3
|
+
|
|
4
|
+
Shows user avatar image with fallback to initials.
|
|
5
|
+
Used internally by messaging components.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
interface Props {
|
|
10
|
+
name: string
|
|
11
|
+
avatarUrl?: string | null
|
|
12
|
+
size?: 'xs' | 'small' | 'medium' | 'large'
|
|
13
|
+
showStatus?: boolean
|
|
14
|
+
status?: 'online' | 'offline' | 'away' | 'busy'
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
name,
|
|
20
|
+
avatarUrl = null,
|
|
21
|
+
size = 'small',
|
|
22
|
+
showStatus = false,
|
|
23
|
+
status = 'offline',
|
|
24
|
+
class: className = '',
|
|
25
|
+
}: Props = $props()
|
|
26
|
+
|
|
27
|
+
const sizeClasses: Record<string, string> = {
|
|
28
|
+
xs: 'w-5 h-5 text-[10px]',
|
|
29
|
+
small: 'w-8 h-8 text-xs',
|
|
30
|
+
medium: 'w-10 h-10 text-sm',
|
|
31
|
+
large: 'w-12 h-12 text-base',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const statusDotSize: Record<string, string> = {
|
|
35
|
+
xs: 'w-1.5 h-1.5',
|
|
36
|
+
small: 'w-2 h-2',
|
|
37
|
+
medium: 'w-2.5 h-2.5',
|
|
38
|
+
large: 'w-3 h-3',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const statusColors: Record<string, string> = {
|
|
42
|
+
online: 'bg-success',
|
|
43
|
+
offline: 'bg-base-content/30',
|
|
44
|
+
away: 'bg-warning',
|
|
45
|
+
busy: 'bg-error',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getInitials(fullName: string): string {
|
|
49
|
+
return fullName
|
|
50
|
+
.split(' ')
|
|
51
|
+
.map((part) => part[0])
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
.join('')
|
|
55
|
+
.toUpperCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const initials = $derived(getInitials(name))
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<div class="relative inline-flex flex-shrink-0 {className}">
|
|
62
|
+
{#if avatarUrl}
|
|
63
|
+
<img
|
|
64
|
+
src={avatarUrl}
|
|
65
|
+
alt={name}
|
|
66
|
+
class="rounded-full object-cover {sizeClasses[size]}"
|
|
67
|
+
/>
|
|
68
|
+
{:else}
|
|
69
|
+
<div
|
|
70
|
+
class="rounded-full bg-neutral text-neutral-content flex items-center justify-center font-medium {sizeClasses[size]}"
|
|
71
|
+
>
|
|
72
|
+
{initials}
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{#if showStatus}
|
|
77
|
+
<span
|
|
78
|
+
class="absolute bottom-0 right-0 rounded-full border-2 border-base-100 {statusDotSize[size]} {statusColors[status]}"
|
|
79
|
+
></span>
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ChannelInfoModal Component
|
|
3
|
+
|
|
4
|
+
Displays channel information and allows admins to edit channel details.
|
|
5
|
+
All updates via callbacks.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Channel, ChannelCallbacks } from '../../types/messaging'
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
channel: Channel & { memberCount?: number; createdBy?: string; creatorName?: string }
|
|
13
|
+
currentUserRole: 'admin' | 'moderator' | 'member'
|
|
14
|
+
open: boolean
|
|
15
|
+
channelCallbacks?: ChannelCallbacks
|
|
16
|
+
onclose?: () => void
|
|
17
|
+
onchannelupdate?: (updatedChannel: Channel) => void
|
|
18
|
+
onchanneldelete?: (channelId: string) => void
|
|
19
|
+
onmanagemembers?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
channel,
|
|
24
|
+
currentUserRole,
|
|
25
|
+
open = false,
|
|
26
|
+
channelCallbacks,
|
|
27
|
+
onclose,
|
|
28
|
+
onchannelupdate,
|
|
29
|
+
onchanneldelete,
|
|
30
|
+
onmanagemembers,
|
|
31
|
+
}: Props = $props()
|
|
32
|
+
|
|
33
|
+
let isEditing = $state(false)
|
|
34
|
+
let editName = $state('')
|
|
35
|
+
let editDescription = $state('')
|
|
36
|
+
let saving = $state(false)
|
|
37
|
+
let showDeleteConfirm = $state(false)
|
|
38
|
+
let error = $state<string | null>(null)
|
|
39
|
+
|
|
40
|
+
const isAdmin = $derived(currentUserRole === 'admin')
|
|
41
|
+
|
|
42
|
+
function startEdit() {
|
|
43
|
+
editName = channel.name
|
|
44
|
+
editDescription = channel.description || ''
|
|
45
|
+
isEditing = true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveEdit() {
|
|
49
|
+
if (!channelCallbacks || !editName.trim()) return
|
|
50
|
+
saving = true
|
|
51
|
+
error = null
|
|
52
|
+
try {
|
|
53
|
+
const updated = await channelCallbacks.updateChannel(channel.id, {
|
|
54
|
+
name: editName.trim(),
|
|
55
|
+
description: editDescription.trim() || undefined,
|
|
56
|
+
})
|
|
57
|
+
onchannelupdate?.(updated)
|
|
58
|
+
isEditing = false
|
|
59
|
+
} catch (err) {
|
|
60
|
+
error = err instanceof Error ? err.message : 'Failed to update channel'
|
|
61
|
+
} finally {
|
|
62
|
+
saving = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handleDelete() {
|
|
67
|
+
if (!channelCallbacks) return
|
|
68
|
+
try {
|
|
69
|
+
await channelCallbacks.deleteChannel(channel.id)
|
|
70
|
+
onchanneldelete?.(channel.id)
|
|
71
|
+
onclose?.()
|
|
72
|
+
} catch (err) {
|
|
73
|
+
error = err instanceof Error ? err.message : 'Failed to delete channel'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatDate(dateString: string): string {
|
|
78
|
+
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
{#if open}
|
|
83
|
+
<dialog class="modal modal-open">
|
|
84
|
+
<div class="modal-box max-w-md">
|
|
85
|
+
<div class="flex items-center justify-between mb-4">
|
|
86
|
+
<h3 class="font-bold text-lg">
|
|
87
|
+
{channel.emoji || '#'} {channel.name}
|
|
88
|
+
</h3>
|
|
89
|
+
<button class="btn btn-ghost btn-sm btn-circle" onclick={onclose}>✕</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{#if error}
|
|
93
|
+
<div class="alert alert-error mb-4"><span class="text-sm">{error}</span></div>
|
|
94
|
+
{/if}
|
|
95
|
+
|
|
96
|
+
{#if isEditing}
|
|
97
|
+
<div class="space-y-4">
|
|
98
|
+
<div>
|
|
99
|
+
<label class="label"><span class="label-text font-medium">Name</span></label>
|
|
100
|
+
<input type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<label class="label"><span class="label-text font-medium">Description</span></label>
|
|
104
|
+
<textarea bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="flex gap-2">
|
|
107
|
+
<button class="btn btn-primary btn-sm" onclick={saveEdit} disabled={saving || !editName.trim()}>
|
|
108
|
+
{#if saving}<span class="loading loading-spinner loading-xs"></span>{/if}
|
|
109
|
+
Save
|
|
110
|
+
</button>
|
|
111
|
+
<button class="btn btn-ghost btn-sm" onclick={() => (isEditing = false)}>Cancel</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
{:else}
|
|
115
|
+
<div class="space-y-4">
|
|
116
|
+
<div>
|
|
117
|
+
<span class="text-xs text-base-content/50 uppercase tracking-wider">Type</span>
|
|
118
|
+
<p class="text-sm">{channel.type === 'private' ? '🔒 Private' : '# Public'}</p>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{#if channel.description}
|
|
122
|
+
<div>
|
|
123
|
+
<span class="text-xs text-base-content/50 uppercase tracking-wider">Description</span>
|
|
124
|
+
<p class="text-sm">{channel.description}</p>
|
|
125
|
+
</div>
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
128
|
+
<div>
|
|
129
|
+
<span class="text-xs text-base-content/50 uppercase tracking-wider">Created</span>
|
|
130
|
+
<p class="text-sm">{formatDate(channel.createdAt)}{channel.creatorName ? ` by ${channel.creatorName}` : ''}</p>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="flex flex-wrap gap-2">
|
|
134
|
+
{#if isAdmin}
|
|
135
|
+
<button class="btn btn-sm btn-ghost" onclick={startEdit}>✏ Edit</button>
|
|
136
|
+
{/if}
|
|
137
|
+
{#if onmanagemembers}
|
|
138
|
+
<button class="btn btn-sm btn-ghost" onclick={onmanagemembers}>👥 Members</button>
|
|
139
|
+
{/if}
|
|
140
|
+
{#if isAdmin}
|
|
141
|
+
{#if showDeleteConfirm}
|
|
142
|
+
<div class="flex items-center gap-2">
|
|
143
|
+
<span class="text-sm text-error">Delete this channel?</span>
|
|
144
|
+
<button class="btn btn-sm btn-error" onclick={handleDelete}>Confirm</button>
|
|
145
|
+
<button class="btn btn-sm btn-ghost" onclick={() => (showDeleteConfirm = false)}>Cancel</button>
|
|
146
|
+
</div>
|
|
147
|
+
{:else}
|
|
148
|
+
<button class="btn btn-sm btn-ghost text-error" onclick={() => (showDeleteConfirm = true)}>🗑 Delete</button>
|
|
149
|
+
{/if}
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{/if}
|
|
154
|
+
|
|
155
|
+
<div class="modal-action">
|
|
156
|
+
<button class="btn btn-ghost" onclick={onclose}>Close</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<form method="dialog" class="modal-backdrop">
|
|
160
|
+
<button onclick={onclose}>close</button>
|
|
161
|
+
</form>
|
|
162
|
+
</dialog>
|
|
163
|
+
{/if}
|