@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.
Files changed (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. 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}