@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,260 @@
1
+ <!--
2
+ EmojiSelector Component
3
+
4
+ Emoji picker with categories, search, skin tones, and custom emoji support.
5
+ Custom emojis and preferences are loaded via optional callbacks.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import { onMount, onDestroy } from 'svelte'
10
+ import type {
11
+ EmojiSelection,
12
+ EmojiCategory,
13
+ SkinTone,
14
+ StandardEmoji,
15
+ CustomEmoji,
16
+ EmojiPreferences,
17
+ EmojiCallbacks,
18
+ } from '../../types/messaging'
19
+ import { EMOJI_CATEGORIES, SKIN_TONES } from '../../types/messaging'
20
+
21
+ interface Props {
22
+ visible: boolean
23
+ position: { x: number; y: number }
24
+ onselect: (selection: EmojiSelection) => void
25
+ onclose: () => void
26
+ emojis?: StandardEmoji[]
27
+ customEmojis?: CustomEmoji[]
28
+ preferences?: EmojiPreferences
29
+ callbacks?: EmojiCallbacks
30
+ searchPlaceholder?: string
31
+ maxRecent?: number
32
+ }
33
+
34
+ let {
35
+ visible = false,
36
+ position = { x: 0, y: 0 },
37
+ onselect,
38
+ onclose,
39
+ emojis = [],
40
+ customEmojis = [],
41
+ preferences,
42
+ callbacks,
43
+ searchPlaceholder = 'Search emojis...',
44
+ maxRecent = 24,
45
+ }: Props = $props()
46
+
47
+ let searchQuery = $state('')
48
+ let activeCategory: EmojiCategory = $state('smileys_people')
49
+ let selectedSkinTone: SkinTone = $state(preferences?.skinTone || 'default')
50
+ let hoveredEmoji = $state<{ emoji: string; name: string; shortcode: string } | null>(null)
51
+ let loading = $state(false)
52
+ let selectorElement = $state<HTMLElement | null>(null)
53
+ let searchInput = $state<HTMLInputElement | null>(null)
54
+
55
+ // Category icons using emoji characters
56
+ const categoryEmojis: Record<string, string> = {
57
+ search: '🔍',
58
+ frequently_used: '🕐',
59
+ smileys_people: '😀',
60
+ animals_nature: '🐻',
61
+ food_drink: '🍎',
62
+ travel_places: '🏠',
63
+ activities: '⚽',
64
+ objects: '🔨',
65
+ symbols: '❤️',
66
+ flags: '🏳️',
67
+ custom: '🎨',
68
+ }
69
+
70
+ // Filter emojis by category
71
+ function getEmojisByCategory(category: EmojiCategory): StandardEmoji[] {
72
+ return emojis.filter((e) => e.category === category)
73
+ }
74
+
75
+ // Search emojis
76
+ function searchEmojis(query: string): StandardEmoji[] {
77
+ const q = query.toLowerCase()
78
+ return emojis.filter(
79
+ (e) =>
80
+ e.name.toLowerCase().includes(q) ||
81
+ e.shortcode.toLowerCase().includes(q) ||
82
+ e.keywords.some((k) => k.toLowerCase().includes(q)),
83
+ )
84
+ }
85
+
86
+ const displayEmojis = $derived(
87
+ searchQuery ? searchEmojis(searchQuery) : getEmojisByCategory(activeCategory),
88
+ )
89
+
90
+ function handleSelect(emoji: StandardEmoji) {
91
+ const selection: EmojiSelection = {
92
+ type: 'standard',
93
+ unicode: emoji.unicode,
94
+ name: emoji.name,
95
+ shortcode: emoji.shortcode,
96
+ skinTone: selectedSkinTone,
97
+ }
98
+ onselect(selection)
99
+ }
100
+
101
+ function handleCustomSelect(custom: CustomEmoji) {
102
+ const selection: EmojiSelection = {
103
+ type: 'custom',
104
+ customEmojiId: custom.id,
105
+ name: custom.displayName,
106
+ shortcode: custom.name,
107
+ }
108
+ onselect(selection)
109
+ }
110
+
111
+ // Close on click outside
112
+ function handleClickOutside(event: MouseEvent) {
113
+ if (selectorElement && !selectorElement.contains(event.target as Node)) {
114
+ onclose()
115
+ }
116
+ }
117
+
118
+ // Close on escape
119
+ function handleKeydown(event: KeyboardEvent) {
120
+ if (event.key === 'Escape') onclose()
121
+ }
122
+
123
+ // Load custom emojis and preferences from callbacks on mount
124
+ $effect(() => {
125
+ if (visible && callbacks) {
126
+ loading = true
127
+ Promise.all([
128
+ callbacks.loadCustomEmojis?.().catch(() => []),
129
+ callbacks.loadPreferences?.().catch(() => null),
130
+ ]).then(([loadedCustom, loadedPrefs]) => {
131
+ if (loadedCustom?.length) customEmojis = loadedCustom
132
+ if (loadedPrefs) {
133
+ selectedSkinTone = loadedPrefs.skinTone || 'default'
134
+ }
135
+ loading = false
136
+ })
137
+ }
138
+ })
139
+
140
+ $effect(() => {
141
+ if (visible) {
142
+ document.addEventListener('click', handleClickOutside)
143
+ document.addEventListener('keydown', handleKeydown)
144
+ // Focus search on open
145
+ setTimeout(() => searchInput?.focus(), 100)
146
+ return () => {
147
+ document.removeEventListener('click', handleClickOutside)
148
+ document.removeEventListener('keydown', handleKeydown)
149
+ }
150
+ }
151
+ })
152
+
153
+ // Visible categories (skip search and custom if no custom emojis)
154
+ const visibleCategories = $derived(
155
+ EMOJI_CATEGORIES.filter((cat) => {
156
+ if (cat.id === 'search') return false
157
+ if (cat.id === 'custom' && customEmojis.length === 0) return false
158
+ if (cat.id === 'frequently_used') return false // TODO: track frequent
159
+ return true
160
+ }),
161
+ )
162
+ </script>
163
+
164
+ {#if visible}
165
+ <div
166
+ bind:this={selectorElement}
167
+ class="fixed z-50 bg-base-100 border border-base-300 rounded-box shadow-xl w-88 flex flex-col"
168
+ style="left: {position.x}px; top: {position.y}px; max-height: 420px;"
169
+ >
170
+ <!-- Search -->
171
+ <div class="p-2 border-b border-base-300">
172
+ <input
173
+ bind:this={searchInput}
174
+ bind:value={searchQuery}
175
+ type="text"
176
+ placeholder={searchPlaceholder}
177
+ class="input input-sm input-bordered w-full"
178
+ />
179
+ </div>
180
+
181
+ <!-- Category tabs -->
182
+ {#if !searchQuery}
183
+ <div class="flex items-center gap-0.5 px-2 py-1 border-b border-base-300 overflow-x-auto">
184
+ {#each visibleCategories as cat}
185
+ <button
186
+ class="btn btn-ghost btn-xs btn-square {activeCategory === cat.id ? 'bg-primary/10 text-primary' : ''}"
187
+ onclick={() => (activeCategory = cat.id)}
188
+ title={cat.name}
189
+ >
190
+ {categoryEmojis[cat.id] || '❓'}
191
+ </button>
192
+ {/each}
193
+ </div>
194
+ {/if}
195
+
196
+ <!-- Emoji grid -->
197
+ <div class="flex-1 overflow-y-auto p-2 min-h-[200px]">
198
+ {#if loading}
199
+ <div class="flex items-center justify-center h-full">
200
+ <span class="loading loading-spinner loading-sm"></span>
201
+ </div>
202
+ {:else if searchQuery && displayEmojis.length === 0}
203
+ <div class="flex items-center justify-center h-full text-base-content/50 text-sm">
204
+ No emojis found
205
+ </div>
206
+ {:else if activeCategory === 'custom' && !searchQuery}
207
+ <!-- Custom emojis grid -->
208
+ <div class="grid grid-cols-8 gap-1">
209
+ {#each customEmojis as custom}
210
+ <button
211
+ class="w-8 h-8 flex items-center justify-center rounded hover:bg-base-200 transition-colors text-lg"
212
+ onclick={() => handleCustomSelect(custom)}
213
+ onmouseenter={() => (hoveredEmoji = { emoji: '🎨', name: custom.displayName, shortcode: custom.name })}
214
+ onmouseleave={() => (hoveredEmoji = null)}
215
+ title={custom.displayName}
216
+ >
217
+ 🎨
218
+ </button>
219
+ {/each}
220
+ </div>
221
+ {:else}
222
+ <div class="grid grid-cols-8 gap-1">
223
+ {#each displayEmojis as emoji}
224
+ <button
225
+ class="w-8 h-8 flex items-center justify-center rounded hover:bg-base-200 transition-colors text-lg"
226
+ onclick={() => handleSelect(emoji)}
227
+ onmouseenter={() => (hoveredEmoji = { emoji: emoji.unicode, name: emoji.name, shortcode: emoji.shortcode })}
228
+ onmouseleave={() => (hoveredEmoji = null)}
229
+ title="{emoji.name} :{emoji.shortcode}:"
230
+ >
231
+ {emoji.unicode}
232
+ </button>
233
+ {/each}
234
+ </div>
235
+ {/if}
236
+ </div>
237
+
238
+ <!-- Skin tone selector -->
239
+ <div class="flex items-center justify-between px-2 py-1 border-t border-base-300">
240
+ <div class="flex items-center gap-1">
241
+ {#each SKIN_TONES as tone}
242
+ <button
243
+ class="w-6 h-6 flex items-center justify-center rounded text-sm {selectedSkinTone === tone.id ? 'bg-primary/20 ring-1 ring-primary' : 'hover:bg-base-200'}"
244
+ onclick={() => (selectedSkinTone = tone.id)}
245
+ title={tone.name}
246
+ >
247
+ {tone.preview}
248
+ </button>
249
+ {/each}
250
+ </div>
251
+
252
+ <!-- Hovered emoji info -->
253
+ {#if hoveredEmoji}
254
+ <div class="text-xs text-base-content/60 truncate max-w-[150px]">
255
+ {hoveredEmoji.emoji} :{hoveredEmoji.shortcode}:
256
+ </div>
257
+ {/if}
258
+ </div>
259
+ </div>
260
+ {/if}
@@ -0,0 +1,193 @@
1
+ <!--
2
+ MentionAutocomplete Component
3
+
4
+ Shows search results for @-mention autocomplete.
5
+ Receives search results via callback — no direct API calls.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import { onMount, onDestroy } from 'svelte'
10
+ import type { MentionItem } from '../../types/messaging'
11
+
12
+ interface Props {
13
+ visible: boolean
14
+ query: string
15
+ position: { x: number; y: number }
16
+ onsearch: (query: string) => Promise<MentionItem[]>
17
+ onselect?: (data: { result: MentionItem }) => void
18
+ onclose?: () => void
19
+ }
20
+
21
+ let { visible, query, position, onsearch, onselect, onclose }: Props = $props()
22
+
23
+ let results = $state<MentionItem[]>([])
24
+ let loading = $state(false)
25
+ let selectedIndex = $state(0)
26
+ let dropdownElement = $state<HTMLElement | undefined>(undefined)
27
+ let searchTimeout: ReturnType<typeof setTimeout>
28
+
29
+ // Debounced search
30
+ $effect(() => {
31
+ if (visible && query && query.length >= 2) {
32
+ clearTimeout(searchTimeout)
33
+ searchTimeout = setTimeout(async () => {
34
+ loading = true
35
+ try {
36
+ results = await onsearch(query)
37
+ selectedIndex = 0
38
+ } catch {
39
+ results = []
40
+ } finally {
41
+ loading = false
42
+ }
43
+ }, 300)
44
+ } else if (!visible || query.length < 2) {
45
+ results = []
46
+ }
47
+
48
+ return () => clearTimeout(searchTimeout)
49
+ })
50
+
51
+ function handleKeyDown(event: KeyboardEvent) {
52
+ if (!visible || results.length === 0) return
53
+
54
+ switch (event.key) {
55
+ case 'ArrowDown':
56
+ event.preventDefault()
57
+ selectedIndex = Math.min(selectedIndex + 1, results.length - 1)
58
+ break
59
+ case 'ArrowUp':
60
+ event.preventDefault()
61
+ selectedIndex = Math.max(selectedIndex - 1, 0)
62
+ break
63
+ case 'Enter':
64
+ case 'Tab':
65
+ event.preventDefault()
66
+ if (results[selectedIndex]) {
67
+ onselect?.({ result: results[selectedIndex] })
68
+ }
69
+ break
70
+ case 'Escape':
71
+ event.preventDefault()
72
+ onclose?.()
73
+ break
74
+ }
75
+ }
76
+
77
+ function handleSelect(result: MentionItem) {
78
+ onselect?.({ result })
79
+ }
80
+
81
+ function handleClickOutside(event: MouseEvent) {
82
+ const target = event.target as Node
83
+ if (dropdownElement?.contains(target)) return
84
+ if (
85
+ target instanceof HTMLTextAreaElement ||
86
+ target instanceof HTMLInputElement ||
87
+ (target as Element).closest?.('[contenteditable]')
88
+ ) {
89
+ return
90
+ }
91
+ onclose?.()
92
+ }
93
+
94
+ onMount(() => {
95
+ document.addEventListener('keydown', handleKeyDown)
96
+ })
97
+
98
+ onDestroy(() => {
99
+ document.removeEventListener('keydown', handleKeyDown)
100
+ document.removeEventListener('click', handleClickOutside)
101
+ })
102
+
103
+ $effect(() => {
104
+ if (visible) {
105
+ const timeoutId = setTimeout(() => {
106
+ document.addEventListener('click', handleClickOutside)
107
+ }, 100)
108
+ return () => {
109
+ clearTimeout(timeoutId)
110
+ document.removeEventListener('click', handleClickOutside)
111
+ }
112
+ } else {
113
+ document.removeEventListener('click', handleClickOutside)
114
+ }
115
+ })
116
+
117
+ let dropdownStyle = $state('')
118
+
119
+ $effect(() => {
120
+ if (!visible) {
121
+ dropdownStyle = 'display: none;'
122
+ return
123
+ }
124
+
125
+ const maxHeight = 400
126
+ const itemHeight = 60
127
+ const footerHeight = results.length > 0 ? 40 : 0
128
+ const estimatedHeight = Math.min(
129
+ maxHeight,
130
+ Math.max(120, results.length * itemHeight + footerHeight + 20),
131
+ )
132
+
133
+ let top = position.y - estimatedHeight - 24
134
+ const left = position.x
135
+
136
+ if (top < 10) {
137
+ top = position.y + 20
138
+ }
139
+
140
+ dropdownStyle = `position: fixed; top: ${top}px; left: ${left}px; z-index: 1000; max-height: ${maxHeight}px; width: 320px;`
141
+ })
142
+ </script>
143
+
144
+ {#if visible}
145
+ <div
146
+ bind:this={dropdownElement}
147
+ class="bg-base-100 border border-base-300 rounded-box shadow-lg overflow-y-auto overflow-x-hidden flex flex-col"
148
+ style={dropdownStyle}
149
+ >
150
+ {#if loading}
151
+ <div class="p-4 flex items-center justify-center">
152
+ <span class="loading loading-spinner loading-sm mr-2"></span>
153
+ <span class="text-sm text-base-content/70">Searching...</span>
154
+ </div>
155
+ {:else if results.length === 0}
156
+ <div class="p-4 text-center">
157
+ <span class="text-sm text-base-content/50">
158
+ {query.length < 2 ? 'Type to search...' : `No results found for "${query}"`}
159
+ </span>
160
+ </div>
161
+ {:else}
162
+ {#each results as result, index}
163
+ <button
164
+ type="button"
165
+ class="flex items-center gap-3 p-4 text-left hover:bg-base-200 transition-colors w-full border-b border-base-200 last:border-b-0 min-h-[60px] flex-shrink-0
166
+ {selectedIndex === index ? 'bg-primary/10 text-primary' : 'text-base-content'}"
167
+ onclick={() => handleSelect(result)}
168
+ >
169
+ <div class="flex-1 min-w-0">
170
+ <div class="font-medium text-sm truncate">{result.name}</div>
171
+ {#if result.secondary}
172
+ <div class="text-xs text-base-content/60 truncate">{result.secondary}</div>
173
+ {/if}
174
+ </div>
175
+
176
+ <div class="flex-shrink-0">
177
+ <span class="badge badge-ghost badge-xs">{result.type}</span>
178
+ </div>
179
+ </button>
180
+ {/each}
181
+ {/if}
182
+
183
+ {#if results.length > 0}
184
+ <div class="px-3 py-2 border-t border-base-300 bg-base-50 flex-shrink-0">
185
+ <div class="text-xs text-base-content/50 flex justify-between">
186
+ <span>↑↓ Navigate</span>
187
+ <span>Tab/Enter Select</span>
188
+ <span>Esc Close</span>
189
+ </div>
190
+ </div>
191
+ {/if}
192
+ </div>
193
+ {/if}