@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,107 @@
1
+ <!--
2
+ ChannelList Component
3
+
4
+ Displays list of channels with unread counts.
5
+ Purely presentational — receives channels via props.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { Channel } from '../../types/messaging'
10
+
11
+ interface Props {
12
+ channels: Channel[]
13
+ activeChannelId?: string | null
14
+ onchannelselect?: (event: { channelId: string }) => void
15
+ }
16
+
17
+ let {
18
+ channels = [],
19
+ activeChannelId = null,
20
+ onchannelselect,
21
+ }: Props = $props()
22
+
23
+ function selectChannel(channelId: string) {
24
+ onchannelselect?.({ channelId })
25
+ }
26
+
27
+ function getChannelIconColor(type: string) {
28
+ switch (type) {
29
+ case 'private': return 'text-warning'
30
+ case 'direct': return 'text-info'
31
+ default: return 'text-base-content/50'
32
+ }
33
+ }
34
+
35
+ const sortedChannels = $derived(
36
+ [...(channels || [])].sort((a, b) => {
37
+ const typeOrder = { public: 0, private: 1, direct: 2 } as Record<string, number>
38
+ const aOrder = typeOrder[a.type] ?? 3
39
+ const bOrder = typeOrder[b.type] ?? 3
40
+ if (aOrder !== bOrder) return aOrder - bOrder
41
+ return a.name.localeCompare(b.name)
42
+ }),
43
+ )
44
+ </script>
45
+
46
+ <div class="space-y-1">
47
+ {#each sortedChannels as channel}
48
+ {@const iconColor = getChannelIconColor(channel.type)}
49
+
50
+ <button
51
+ class="w-full flex items-center gap-2 px-2 py-1.5 text-left rounded hover:bg-base-300 transition-colors group"
52
+ class:bg-primary={activeChannelId === channel.id}
53
+ class:text-primary-content={activeChannelId === channel.id}
54
+ class:font-medium={activeChannelId === channel.id}
55
+ onclick={() => selectChannel(channel.id)}
56
+ >
57
+ <!-- Channel icon: emoji, lock, users, or # -->
58
+ <span
59
+ class="w-4 h-4 flex-shrink-0 text-sm flex items-center justify-center {activeChannelId === channel.id ? 'text-primary-content' : iconColor}"
60
+ >
61
+ {#if channel.type === 'private'}
62
+ 🔒
63
+ {:else if channel.type === 'direct'}
64
+ 👥
65
+ {:else}
66
+ {channel.emoji || '#'}
67
+ {/if}
68
+ </span>
69
+
70
+ <div class="flex-1 min-w-0">
71
+ <div class="flex items-center justify-between">
72
+ <span class="text-sm truncate">
73
+ {channel.name}
74
+ </span>
75
+
76
+ {#if channel.unreadCount && channel.unreadCount > 0}
77
+ <span class="badge badge-error badge-xs text-white">
78
+ {channel.unreadCount > 99 ? '99+' : channel.unreadCount}
79
+ </span>
80
+ {/if}
81
+ </div>
82
+
83
+ {#if channel.description && activeChannelId !== channel.id}
84
+ <div class="text-xs text-base-content/50 truncate">
85
+ {channel.description}
86
+ </div>
87
+ {/if}
88
+ </div>
89
+
90
+ <!-- Admin indicator -->
91
+ {#if channel.memberRole === 'admin'}
92
+ <div
93
+ class="w-2 h-2 rounded-full bg-warning opacity-0 group-hover:opacity-100 transition-opacity"
94
+ title="Channel Admin"
95
+ ></div>
96
+ {/if}
97
+ </button>
98
+ {/each}
99
+
100
+ {#if channels.length === 0}
101
+ <div class="text-center py-8 text-base-content/50">
102
+ <div class="text-2xl mb-2">#</div>
103
+ <div class="text-sm">No channels yet</div>
104
+ <div class="text-xs">Create one to get started</div>
105
+ </div>
106
+ {/if}
107
+ </div>
@@ -0,0 +1,69 @@
1
+ <!--
2
+ ChannelMemberAvatarStack Component
3
+
4
+ Displays a stack of member avatars with overflow count.
5
+ Purely presentational — no backend dependencies.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { ChannelMember, PresenceState } from '../../types/messaging'
10
+ import Avatar from './Avatar.svelte'
11
+
12
+ interface Props {
13
+ members: ChannelMember[]
14
+ presenceState?: PresenceState
15
+ maxVisible?: number
16
+ size?: 'xs' | 'small' | 'medium' | 'large'
17
+ class?: string
18
+ }
19
+
20
+ let {
21
+ members = [],
22
+ presenceState = {},
23
+ maxVisible = 4,
24
+ size = 'small',
25
+ class: className = '',
26
+ }: Props = $props()
27
+
28
+ const visibleMembers = $derived(members.slice(0, maxVisible))
29
+ const remainingCount = $derived(Math.max(0, members.length - maxVisible))
30
+
31
+ function getStatus(member: ChannelMember): 'online' | 'offline' | 'away' | 'busy' {
32
+ const presence = presenceState[member.userId]
33
+ if (presence?.status === 'online' || presence?.status === 'available') {
34
+ return 'online'
35
+ }
36
+ return 'offline'
37
+ }
38
+
39
+ const overflowSize: Record<string, string> = {
40
+ xs: 'w-5 h-5 text-[8px]',
41
+ small: 'w-8 h-8 text-xs',
42
+ medium: 'w-10 h-10 text-sm',
43
+ large: 'w-12 h-12 text-base',
44
+ }
45
+ </script>
46
+
47
+ <div class="flex -space-x-2 {className}">
48
+ {#each visibleMembers as member (member.id)}
49
+ <div class="border-2 border-base-100 rounded-full">
50
+ <Avatar
51
+ name={member.fullName}
52
+ avatarUrl={member.avatarUrl}
53
+ {size}
54
+ showStatus={true}
55
+ status={getStatus(member)}
56
+ />
57
+ </div>
58
+ {/each}
59
+
60
+ {#if remainingCount > 0}
61
+ <div class="border-2 border-base-100 rounded-full">
62
+ <div
63
+ class="bg-neutral text-neutral-content rounded-full flex items-center justify-center {overflowSize[size]}"
64
+ >
65
+ <span class="font-bold">+{remainingCount}</span>
66
+ </div>
67
+ </div>
68
+ {/if}
69
+ </div>
@@ -0,0 +1,182 @@
1
+ <!--
2
+ ChannelMembersModal Component
3
+
4
+ Manages channel members — view, add, remove, change roles.
5
+ All operations via callbacks.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { ChannelMember, OrgMember, ChannelCallbacks } from '../../types/messaging'
10
+ import Avatar from './Avatar.svelte'
11
+
12
+ interface Props {
13
+ channelId: string
14
+ channelName: string
15
+ currentUserRole: 'admin' | 'moderator' | 'member'
16
+ currentUserId: string
17
+ open: boolean
18
+ members?: ChannelMember[]
19
+ orgMembers?: OrgMember[]
20
+ channelCallbacks: ChannelCallbacks
21
+ onclose?: () => void
22
+ onmemberupdate?: () => void
23
+ }
24
+
25
+ let {
26
+ channelId,
27
+ channelName,
28
+ currentUserRole,
29
+ currentUserId,
30
+ open = false,
31
+ members = [],
32
+ orgMembers = [],
33
+ channelCallbacks,
34
+ onclose,
35
+ onmemberupdate,
36
+ }: Props = $props()
37
+
38
+ let searchQuery = $state('')
39
+ let showAddMembers = $state(false)
40
+ let loading = $state(false)
41
+ let error = $state<string | null>(null)
42
+
43
+ const isAdmin = $derived(currentUserRole === 'admin')
44
+
45
+ const filteredMembers = $derived(
46
+ members.filter((m) => !searchQuery || m.fullName.toLowerCase().includes(searchQuery.toLowerCase())),
47
+ )
48
+
49
+ const availableMembers = $derived(
50
+ orgMembers
51
+ .filter((m) => !members.some((cm) => cm.userId === m.id))
52
+ .filter((m) => !searchQuery || m.full_name.toLowerCase().includes(searchQuery.toLowerCase())),
53
+ )
54
+
55
+ const roleIcons: Record<string, string> = { admin: '👑', moderator: '🛡', member: '👤' }
56
+
57
+ async function addMember(userId: string) {
58
+ loading = true; error = null
59
+ try {
60
+ await channelCallbacks.addMember(channelId, userId)
61
+ onmemberupdate?.()
62
+ } catch (err) {
63
+ error = err instanceof Error ? err.message : 'Failed to add member'
64
+ } finally { loading = false }
65
+ }
66
+
67
+ async function removeMember(userId: string) {
68
+ if (!confirm('Remove this member from the channel?')) return
69
+ loading = true; error = null
70
+ try {
71
+ await channelCallbacks.removeMember(channelId, userId)
72
+ onmemberupdate?.()
73
+ } catch (err) {
74
+ error = err instanceof Error ? err.message : 'Failed to remove member'
75
+ } finally { loading = false }
76
+ }
77
+
78
+ async function changeRole(userId: string, role: 'admin' | 'moderator' | 'member') {
79
+ loading = true; error = null
80
+ try {
81
+ await channelCallbacks.updateMemberRole(channelId, userId, role)
82
+ onmemberupdate?.()
83
+ } catch (err) {
84
+ error = err instanceof Error ? err.message : 'Failed to update role'
85
+ } finally { loading = false }
86
+ }
87
+
88
+ async function leaveChannel() {
89
+ if (!confirm(`Leave #${channelName}?`)) return
90
+ try {
91
+ await channelCallbacks.leaveChannel(channelId)
92
+ onclose?.()
93
+ } catch (err) {
94
+ error = err instanceof Error ? err.message : 'Failed to leave channel'
95
+ }
96
+ }
97
+ </script>
98
+
99
+ {#if open}
100
+ <dialog class="modal modal-open">
101
+ <div class="modal-box max-w-md">
102
+ <div class="flex items-center justify-between mb-4">
103
+ <h3 class="font-bold text-lg">Members of #{channelName}</h3>
104
+ <button class="btn btn-ghost btn-sm btn-circle" onclick={onclose}>✕</button>
105
+ </div>
106
+
107
+ {#if error}
108
+ <div class="alert alert-error mb-4"><span class="text-sm">{error}</span></div>
109
+ {/if}
110
+
111
+ <!-- Search -->
112
+ <input type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-3" placeholder="Search members..." />
113
+
114
+ <!-- Toggle: current members / add members -->
115
+ {#if isAdmin}
116
+ <div class="flex gap-2 mb-3">
117
+ <button class="btn btn-sm {!showAddMembers ? 'btn-primary' : 'btn-ghost'}" onclick={() => (showAddMembers = false)}>
118
+ Members ({members.length})
119
+ </button>
120
+ <button class="btn btn-sm {showAddMembers ? 'btn-primary' : 'btn-ghost'}" onclick={() => (showAddMembers = true)}>
121
+ + Add
122
+ </button>
123
+ </div>
124
+ {/if}
125
+
126
+ <div class="max-h-72 overflow-y-auto space-y-1">
127
+ {#if showAddMembers}
128
+ {#each availableMembers as member}
129
+ <div class="flex items-center gap-3 px-2 py-2 rounded hover:bg-base-200">
130
+ <Avatar name={member.full_name} avatarUrl={member.avatar_url} size="small" />
131
+ <div class="flex-1">
132
+ <div class="text-sm font-medium">{member.full_name}</div>
133
+ <div class="text-xs text-base-content/50 capitalize">{member.role}</div>
134
+ </div>
135
+ <button class="btn btn-xs btn-primary" onclick={() => addMember(member.id)} disabled={loading}>Add</button>
136
+ </div>
137
+ {/each}
138
+ {#if availableMembers.length === 0}
139
+ <div class="text-center py-4 text-base-content/50 text-sm">No more members to add</div>
140
+ {/if}
141
+ {:else}
142
+ {#each filteredMembers as member}
143
+ <div class="flex items-center gap-3 px-2 py-2 rounded hover:bg-base-200">
144
+ <Avatar name={member.fullName} avatarUrl={member.avatarUrl} size="small" />
145
+ <div class="flex-1">
146
+ <div class="text-sm font-medium flex items-center gap-1">
147
+ {member.fullName}
148
+ <span class="text-xs">{roleIcons[member.role]}</span>
149
+ </div>
150
+ </div>
151
+ {#if isAdmin && member.userId !== currentUserId}
152
+ <div class="dropdown dropdown-end">
153
+ <button tabindex="0" class="btn btn-ghost btn-xs">⋮</button>
154
+ <ul tabindex="-1" class="dropdown-content menu p-1 shadow bg-base-100 rounded-box w-36 z-50">
155
+ {#if member.role !== 'admin'}
156
+ <li><button onclick={() => changeRole(member.userId, 'admin')}>Make Admin</button></li>
157
+ {/if}
158
+ {#if member.role !== 'moderator'}
159
+ <li><button onclick={() => changeRole(member.userId, 'moderator')}>Make Moderator</button></li>
160
+ {/if}
161
+ {#if member.role !== 'member'}
162
+ <li><button onclick={() => changeRole(member.userId, 'member')}>Make Member</button></li>
163
+ {/if}
164
+ <li><button class="text-error" onclick={() => removeMember(member.userId)}>Remove</button></li>
165
+ </ul>
166
+ </div>
167
+ {/if}
168
+ </div>
169
+ {/each}
170
+ {/if}
171
+ </div>
172
+
173
+ <div class="modal-action justify-between">
174
+ <button class="btn btn-ghost btn-sm text-error" onclick={leaveChannel}>Leave Channel</button>
175
+ <button class="btn btn-ghost" onclick={onclose}>Close</button>
176
+ </div>
177
+ </div>
178
+ <form method="dialog" class="modal-backdrop">
179
+ <button onclick={onclose}>close</button>
180
+ </form>
181
+ </dialog>
182
+ {/if}
@@ -0,0 +1,190 @@
1
+ <!--
2
+ CreateChannelModal Component
3
+
4
+ Modal for creating a new channel with name, description, type, emoji, and member selection.
5
+ All operations via callbacks.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { Channel, OrgMember, EmojiSelection, ChannelCallbacks } from '../../types/messaging'
10
+ import Avatar from './Avatar.svelte'
11
+ import EmojiSelector from './EmojiSelector.svelte'
12
+
13
+ interface Props {
14
+ open: boolean
15
+ currentUserId: string
16
+ orgMembers?: OrgMember[]
17
+ channelCallbacks: ChannelCallbacks
18
+ onclose?: () => void
19
+ onchannelcreated?: (event: { channel: Channel }) => void
20
+ }
21
+
22
+ let {
23
+ open = false,
24
+ currentUserId,
25
+ orgMembers = [],
26
+ channelCallbacks,
27
+ onclose,
28
+ onchannelcreated,
29
+ }: Props = $props()
30
+
31
+ let channelName = $state('')
32
+ let description = $state('')
33
+ let channelType = $state<'public' | 'private'>('public')
34
+ let selectedMembers = $state<string[]>([])
35
+ let searchQuery = $state('')
36
+ let selectedEmoji = $state<string>('💬')
37
+ let showEmojiSelector = $state(false)
38
+ let emojiSelectorPosition = $state({ x: 0, y: 0 })
39
+ let submitting = $state(false)
40
+ let error = $state<string | null>(null)
41
+
42
+ const isValid = $derived(channelName.trim().length > 0 && channelName.trim().length <= 80)
43
+
44
+ const filteredMembers = $derived(
45
+ orgMembers
46
+ .filter((m) => m.id !== currentUserId)
47
+ .filter((m) => !searchQuery || m.full_name.toLowerCase().includes(searchQuery.toLowerCase())),
48
+ )
49
+
50
+ function toggleMember(memberId: string) {
51
+ if (selectedMembers.includes(memberId)) {
52
+ selectedMembers = selectedMembers.filter((id) => id !== memberId)
53
+ } else {
54
+ selectedMembers = [...selectedMembers, memberId]
55
+ }
56
+ }
57
+
58
+ function handleEmojiSelect(selection: EmojiSelection) {
59
+ if (selection.unicode) selectedEmoji = selection.unicode
60
+ showEmojiSelector = false
61
+ }
62
+
63
+ function showEmojiPicker(event: MouseEvent) {
64
+ const rect = (event.target as HTMLElement).getBoundingClientRect()
65
+ emojiSelectorPosition = { x: rect.left, y: rect.bottom + 8 }
66
+ showEmojiSelector = true
67
+ }
68
+
69
+ async function handleSubmit() {
70
+ if (!isValid || submitting) return
71
+ submitting = true
72
+ error = null
73
+
74
+ try {
75
+ const channel = await channelCallbacks.createChannel({
76
+ name: channelName.trim(),
77
+ description: description.trim() || undefined,
78
+ type: channelType,
79
+ emoji: selectedEmoji,
80
+ memberIds: selectedMembers,
81
+ })
82
+ onchannelcreated?.({ channel })
83
+ resetForm()
84
+ onclose?.()
85
+ } catch (err) {
86
+ error = err instanceof Error ? err.message : 'Failed to create channel'
87
+ } finally {
88
+ submitting = false
89
+ }
90
+ }
91
+
92
+ function resetForm() {
93
+ channelName = ''
94
+ description = ''
95
+ channelType = 'public'
96
+ selectedMembers = []
97
+ searchQuery = ''
98
+ selectedEmoji = '💬'
99
+ error = null
100
+ }
101
+
102
+ function handleClose() {
103
+ resetForm()
104
+ onclose?.()
105
+ }
106
+ </script>
107
+
108
+ {#if open}
109
+ <dialog class="modal modal-open">
110
+ <div class="modal-box max-w-lg">
111
+ <div class="flex items-center justify-between mb-4">
112
+ <h3 class="font-bold text-lg">Create Channel</h3>
113
+ <button class="btn btn-ghost btn-sm btn-circle" onclick={handleClose}>✕</button>
114
+ </div>
115
+
116
+ {#if error}
117
+ <div class="alert alert-error mb-4">
118
+ <span class="text-sm">{error}</span>
119
+ </div>
120
+ {/if}
121
+
122
+ <form onsubmit={(e) => { e.preventDefault(); handleSubmit() }} class="space-y-4">
123
+ <!-- Channel name + emoji -->
124
+ <div class="flex items-end gap-2">
125
+ <button type="button" class="btn btn-ghost btn-lg btn-square text-2xl" onclick={showEmojiPicker} title="Choose emoji">
126
+ {selectedEmoji}
127
+ </button>
128
+ <div class="flex-1">
129
+ <label class="label"><span class="label-text font-medium">Channel Name</span></label>
130
+ <input type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Description -->
135
+ <div>
136
+ <label class="label"><span class="label-text font-medium">Description</span></label>
137
+ <textarea bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
138
+ </div>
139
+
140
+ <!-- Channel type -->
141
+ <div>
142
+ <label class="label"><span class="label-text font-medium">Channel Type</span></label>
143
+ <div class="flex gap-3">
144
+ <label class="cursor-pointer flex items-center gap-2">
145
+ <input type="radio" name="channelType" class="radio radio-primary" bind:group={channelType} value="public" />
146
+ <span class="text-sm"># Public</span>
147
+ </label>
148
+ <label class="cursor-pointer flex items-center gap-2">
149
+ <input type="radio" name="channelType" class="radio radio-primary" bind:group={channelType} value="private" />
150
+ <span class="text-sm">🔒 Private</span>
151
+ </label>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Member selection -->
156
+ <div>
157
+ <label class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
158
+ <input type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
159
+ <div class="max-h-40 overflow-y-auto space-y-1">
160
+ {#each filteredMembers as member}
161
+ <button type="button" class="w-full flex items-center gap-3 px-2 py-1.5 rounded hover:bg-base-200 transition-colors" onclick={() => toggleMember(member.id)}>
162
+ <Avatar name={member.full_name} avatarUrl={member.avatar_url} size="xs" />
163
+ <span class="text-sm flex-1 text-left">{member.full_name}</span>
164
+ {#if selectedMembers.includes(member.id)}
165
+ <span class="text-primary">✓</span>
166
+ {/if}
167
+ </button>
168
+ {/each}
169
+ </div>
170
+ </div>
171
+
172
+ <!-- Submit -->
173
+ <div class="modal-action">
174
+ <button type="button" class="btn btn-ghost" onclick={handleClose}>Cancel</button>
175
+ <button type="submit" class="btn btn-primary" disabled={!isValid || submitting}>
176
+ {#if submitting}<span class="loading loading-spinner loading-sm"></span>{/if}
177
+ Create Channel
178
+ </button>
179
+ </div>
180
+ </form>
181
+ </div>
182
+ <form method="dialog" class="modal-backdrop">
183
+ <button onclick={handleClose}>close</button>
184
+ </form>
185
+ </dialog>
186
+ {/if}
187
+
188
+ {#if showEmojiSelector}
189
+ <EmojiSelector visible={showEmojiSelector} position={emojiSelectorPosition} onselect={handleEmojiSelect} onclose={() => (showEmojiSelector = false)} />
190
+ {/if}
@@ -0,0 +1,145 @@
1
+ <!--
2
+ DirectMessageList Component
3
+
4
+ Displays direct message conversations and available users.
5
+ Shows online status and unread counts.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { DirectConversation, OrgMember } from '../../types/messaging'
10
+ import Avatar from './Avatar.svelte'
11
+
12
+ interface Props {
13
+ conversations: DirectConversation[]
14
+ orgMembers: OrgMember[]
15
+ activeDMUserId?: string | null
16
+ onconversationselect?: (data: { userId: string }) => void
17
+ }
18
+
19
+ let {
20
+ conversations = [],
21
+ orgMembers = [],
22
+ activeDMUserId = null,
23
+ onconversationselect,
24
+ }: Props = $props()
25
+
26
+ function selectConversation(userId: string) {
27
+ onconversationselect?.({ userId })
28
+ }
29
+
30
+ function formatLastMessageTime(dateString: string): string {
31
+ const date = new Date(dateString)
32
+ const now = new Date()
33
+ const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60))
34
+
35
+ if (diffInMinutes < 1) return 'now'
36
+ if (diffInMinutes < 60) return `${diffInMinutes}m`
37
+ if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h`
38
+ return `${Math.floor(diffInMinutes / 1440)}d`
39
+ }
40
+
41
+ interface CombinedUser {
42
+ id: string
43
+ name: string
44
+ avatar?: string
45
+ lastMessageAt: string
46
+ unreadCount: number
47
+ isOnline: boolean
48
+ hasRecentConversation: boolean
49
+ role?: string
50
+ }
51
+
52
+ let allUsers = $state<CombinedUser[]>([])
53
+
54
+ $effect(() => {
55
+ if (!conversations || !orgMembers) {
56
+ allUsers = []
57
+ return
58
+ }
59
+
60
+ const conversationUserIds = new Set(conversations.map((conv) => conv.id))
61
+
62
+ const recentConvs: CombinedUser[] = conversations.map((conv) => {
63
+ const orgMember = orgMembers.find((member) => member.id === conv.id)
64
+ return {
65
+ id: conv.id,
66
+ name: conv.name,
67
+ avatar: conv.avatar,
68
+ lastMessageAt: conv.lastMessageAt,
69
+ unreadCount: conv.unreadCount || 0,
70
+ isOnline: orgMember?.active || false,
71
+ hasRecentConversation: true,
72
+ }
73
+ })
74
+
75
+ const otherMembers: CombinedUser[] = orgMembers
76
+ .filter((member) => !conversationUserIds.has(member.id))
77
+ .map((member) => ({
78
+ id: member.id,
79
+ name: member.full_name,
80
+ avatar: member.avatar_url,
81
+ lastMessageAt: '',
82
+ unreadCount: 0,
83
+ isOnline: member.active || false,
84
+ hasRecentConversation: false,
85
+ role: member.role,
86
+ }))
87
+ .sort((a, b) => a.name.localeCompare(b.name))
88
+
89
+ allUsers = [...recentConvs, ...otherMembers]
90
+ })
91
+ </script>
92
+
93
+ <div class="space-y-1">
94
+ {#each allUsers as user}
95
+ <button
96
+ class="w-full flex items-center gap-3 px-2 py-2 text-left rounded hover:bg-base-300 transition-colors"
97
+ class:bg-primary={activeDMUserId === user.id}
98
+ class:text-primary-content={activeDMUserId === user.id}
99
+ class:font-medium={activeDMUserId === user.id}
100
+ onclick={() => selectConversation(user.id)}
101
+ >
102
+ <div class="flex-shrink-0">
103
+ <Avatar
104
+ name={user.name}
105
+ avatarUrl={user.avatar}
106
+ size="small"
107
+ showStatus={true}
108
+ status={user.isOnline ? 'online' : 'offline'}
109
+ />
110
+ </div>
111
+
112
+ <div class="flex-1 min-w-0">
113
+ <div class="flex items-center justify-between">
114
+ <span class="text-sm truncate">{user.name}</span>
115
+
116
+ <div class="flex items-center gap-1">
117
+ {#if user.unreadCount > 0}
118
+ <span class="badge badge-error badge-xs text-white">
119
+ {user.unreadCount > 99 ? '99+' : user.unreadCount}
120
+ </span>
121
+ {/if}
122
+
123
+ {#if user.lastMessageAt}
124
+ <span class="text-xs text-base-content/50">
125
+ {formatLastMessageTime(user.lastMessageAt)}
126
+ </span>
127
+ {/if}
128
+ </div>
129
+ </div>
130
+
131
+ {#if !user.hasRecentConversation && user.role}
132
+ <div class="text-xs text-base-content/50 capitalize">{user.role}</div>
133
+ {/if}
134
+ </div>
135
+ </button>
136
+ {/each}
137
+
138
+ {#if allUsers.length === 0}
139
+ <div class="text-center py-8 text-base-content/50">
140
+ <div class="text-2xl mb-2">💬</div>
141
+ <div class="text-sm">No conversations yet</div>
142
+ <div class="text-xs">Start messaging your team</div>
143
+ </div>
144
+ {/if}
145
+ </div>