@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,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>
|