@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,234 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
NotificationSettingsModal Component
|
|
3
|
+
|
|
4
|
+
Manages user notification preferences for messaging.
|
|
5
|
+
Supports both global settings and per-channel preferences.
|
|
6
|
+
All operations via callbacks.
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import type { NotificationCallbacks } from '../../types/messaging'
|
|
11
|
+
|
|
12
|
+
interface GlobalSettings {
|
|
13
|
+
enable_notifications: boolean
|
|
14
|
+
enable_sound: boolean
|
|
15
|
+
enable_desktop: boolean
|
|
16
|
+
quiet_hours_enabled: boolean
|
|
17
|
+
quiet_hours_start: string
|
|
18
|
+
quiet_hours_end: string
|
|
19
|
+
weekend_notifications: boolean
|
|
20
|
+
show_typing_indicators: boolean
|
|
21
|
+
show_read_receipts: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ChannelPreferences {
|
|
25
|
+
muted: boolean
|
|
26
|
+
mute_until?: string
|
|
27
|
+
notification_level: 'all' | 'mentions' | 'none'
|
|
28
|
+
desktop_notifications: boolean
|
|
29
|
+
sound_enabled: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
type: 'global' | 'channel'
|
|
34
|
+
channelId?: string
|
|
35
|
+
channelName?: string
|
|
36
|
+
open: boolean
|
|
37
|
+
notificationCallbacks: NotificationCallbacks
|
|
38
|
+
onclose?: () => void
|
|
39
|
+
onsettingsupdate?: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
type,
|
|
44
|
+
channelId,
|
|
45
|
+
channelName,
|
|
46
|
+
open = false,
|
|
47
|
+
notificationCallbacks,
|
|
48
|
+
onclose,
|
|
49
|
+
onsettingsupdate,
|
|
50
|
+
}: Props = $props()
|
|
51
|
+
|
|
52
|
+
let loading = $state(true)
|
|
53
|
+
let saving = $state(false)
|
|
54
|
+
let error = $state<string | null>(null)
|
|
55
|
+
|
|
56
|
+
// Global settings
|
|
57
|
+
let globalSettings = $state<GlobalSettings>({
|
|
58
|
+
enable_notifications: true,
|
|
59
|
+
enable_sound: true,
|
|
60
|
+
enable_desktop: false,
|
|
61
|
+
quiet_hours_enabled: false,
|
|
62
|
+
quiet_hours_start: '22:00',
|
|
63
|
+
quiet_hours_end: '08:00',
|
|
64
|
+
weekend_notifications: true,
|
|
65
|
+
show_typing_indicators: true,
|
|
66
|
+
show_read_receipts: true,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Channel preferences
|
|
70
|
+
let channelPrefs = $state<ChannelPreferences>({
|
|
71
|
+
muted: false,
|
|
72
|
+
notification_level: 'all',
|
|
73
|
+
desktop_notifications: true,
|
|
74
|
+
sound_enabled: true,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Load settings when modal opens
|
|
78
|
+
$effect(() => {
|
|
79
|
+
if (open) {
|
|
80
|
+
loadSettings()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
async function loadSettings() {
|
|
85
|
+
loading = true
|
|
86
|
+
error = null
|
|
87
|
+
try {
|
|
88
|
+
const settings = await notificationCallbacks.loadSettings({ type, channelId })
|
|
89
|
+
if (type === 'global' && settings) {
|
|
90
|
+
globalSettings = { ...globalSettings, ...settings } as unknown as GlobalSettings
|
|
91
|
+
} else if (type === 'channel' && settings) {
|
|
92
|
+
channelPrefs = { ...channelPrefs, ...settings } as unknown as ChannelPreferences
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
error = err instanceof Error ? err.message : 'Failed to load settings'
|
|
96
|
+
} finally {
|
|
97
|
+
loading = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function saveSettings() {
|
|
102
|
+
saving = true
|
|
103
|
+
error = null
|
|
104
|
+
try {
|
|
105
|
+
const settings = type === 'global' ? globalSettings : channelPrefs
|
|
106
|
+
await notificationCallbacks.saveSettings(
|
|
107
|
+
{ type, channelId },
|
|
108
|
+
settings as any,
|
|
109
|
+
)
|
|
110
|
+
onsettingsupdate?.()
|
|
111
|
+
onclose?.()
|
|
112
|
+
} catch (err) {
|
|
113
|
+
error = err instanceof Error ? err.message : 'Failed to save settings'
|
|
114
|
+
} finally {
|
|
115
|
+
saving = false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
{#if open}
|
|
121
|
+
<dialog class="modal modal-open">
|
|
122
|
+
<div class="modal-box max-w-md">
|
|
123
|
+
<div class="flex items-center justify-between mb-4">
|
|
124
|
+
<h3 class="font-bold text-lg">
|
|
125
|
+
{type === 'global' ? 'Notification Settings' : `Notifications: #${channelName}`}
|
|
126
|
+
</h3>
|
|
127
|
+
<button class="btn btn-ghost btn-sm btn-circle" onclick={onclose}>✕</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{#if error}
|
|
131
|
+
<div class="alert alert-error mb-4"><span class="text-sm">{error}</span></div>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
{#if loading}
|
|
135
|
+
<div class="flex justify-center py-8">
|
|
136
|
+
<span class="loading loading-spinner loading-md"></span>
|
|
137
|
+
</div>
|
|
138
|
+
{:else if type === 'global'}
|
|
139
|
+
<div class="space-y-4">
|
|
140
|
+
<div class="form-control">
|
|
141
|
+
<label class="label cursor-pointer">
|
|
142
|
+
<span class="label-text">Enable Notifications</span>
|
|
143
|
+
<input type="checkbox" bind:checked={globalSettings.enable_notifications} class="toggle toggle-primary" />
|
|
144
|
+
</label>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="form-control">
|
|
147
|
+
<label class="label cursor-pointer">
|
|
148
|
+
<span class="label-text">Sound</span>
|
|
149
|
+
<input type="checkbox" bind:checked={globalSettings.enable_sound} class="toggle toggle-primary" />
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-control">
|
|
153
|
+
<label class="label cursor-pointer">
|
|
154
|
+
<span class="label-text">Desktop Notifications</span>
|
|
155
|
+
<input type="checkbox" bind:checked={globalSettings.enable_desktop} class="toggle toggle-primary" />
|
|
156
|
+
</label>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="divider text-xs text-base-content/50">Quiet Hours</div>
|
|
159
|
+
<div class="form-control">
|
|
160
|
+
<label class="label cursor-pointer">
|
|
161
|
+
<span class="label-text">Enable Quiet Hours</span>
|
|
162
|
+
<input type="checkbox" bind:checked={globalSettings.quiet_hours_enabled} class="toggle toggle-primary" />
|
|
163
|
+
</label>
|
|
164
|
+
</div>
|
|
165
|
+
{#if globalSettings.quiet_hours_enabled}
|
|
166
|
+
<div class="flex gap-3">
|
|
167
|
+
<div class="flex-1">
|
|
168
|
+
<label class="label"><span class="label-text text-xs">Start</span></label>
|
|
169
|
+
<input type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
|
|
170
|
+
</div>
|
|
171
|
+
<div class="flex-1">
|
|
172
|
+
<label class="label"><span class="label-text text-xs">End</span></label>
|
|
173
|
+
<input type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
{/if}
|
|
177
|
+
<div class="divider text-xs text-base-content/50">Display</div>
|
|
178
|
+
<div class="form-control">
|
|
179
|
+
<label class="label cursor-pointer">
|
|
180
|
+
<span class="label-text">Show Typing Indicators</span>
|
|
181
|
+
<input type="checkbox" bind:checked={globalSettings.show_typing_indicators} class="toggle toggle-primary" />
|
|
182
|
+
</label>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="form-control">
|
|
185
|
+
<label class="label cursor-pointer">
|
|
186
|
+
<span class="label-text">Show Read Receipts</span>
|
|
187
|
+
<input type="checkbox" bind:checked={globalSettings.show_read_receipts} class="toggle toggle-primary" />
|
|
188
|
+
</label>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
{:else}
|
|
192
|
+
<div class="space-y-4">
|
|
193
|
+
<div class="form-control">
|
|
194
|
+
<label class="label cursor-pointer">
|
|
195
|
+
<span class="label-text">Mute Channel</span>
|
|
196
|
+
<input type="checkbox" bind:checked={channelPrefs.muted} class="toggle toggle-warning" />
|
|
197
|
+
</label>
|
|
198
|
+
</div>
|
|
199
|
+
<div>
|
|
200
|
+
<label class="label"><span class="label-text font-medium">Notification Level</span></label>
|
|
201
|
+
<select bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
|
|
202
|
+
<option value="all">All Messages</option>
|
|
203
|
+
<option value="mentions">Mentions Only</option>
|
|
204
|
+
<option value="none">None</option>
|
|
205
|
+
</select>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="form-control">
|
|
208
|
+
<label class="label cursor-pointer">
|
|
209
|
+
<span class="label-text">Desktop Notifications</span>
|
|
210
|
+
<input type="checkbox" bind:checked={channelPrefs.desktop_notifications} class="toggle toggle-primary" />
|
|
211
|
+
</label>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="form-control">
|
|
214
|
+
<label class="label cursor-pointer">
|
|
215
|
+
<span class="label-text">Sound</span>
|
|
216
|
+
<input type="checkbox" bind:checked={channelPrefs.sound_enabled} class="toggle toggle-primary" />
|
|
217
|
+
</label>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
{/if}
|
|
221
|
+
|
|
222
|
+
<div class="modal-action">
|
|
223
|
+
<button class="btn btn-ghost" onclick={onclose}>Cancel</button>
|
|
224
|
+
<button class="btn btn-primary" onclick={saveSettings} disabled={saving || loading}>
|
|
225
|
+
{#if saving}<span class="loading loading-spinner loading-xs"></span>{/if}
|
|
226
|
+
Save
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<form method="dialog" class="modal-backdrop">
|
|
231
|
+
<button onclick={onclose}>close</button>
|
|
232
|
+
</form>
|
|
233
|
+
</dialog>
|
|
234
|
+
{/if}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
QuotedMessageDisplay Component
|
|
3
|
+
|
|
4
|
+
Displays a quoted message with sender info, truncated content, and timestamp.
|
|
5
|
+
Used for both quote previews in input and quoted messages in conversation.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { QuotedMessage } from '../../types/messaging'
|
|
10
|
+
import { renderMentionsAsHTML } from '../../utils/mentionParser'
|
|
11
|
+
import Avatar from './Avatar.svelte'
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
quotedMessage: QuotedMessage
|
|
15
|
+
mode?: 'inline' | 'preview'
|
|
16
|
+
class?: string
|
|
17
|
+
onremove?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
quotedMessage,
|
|
22
|
+
mode = 'inline',
|
|
23
|
+
class: className = '',
|
|
24
|
+
onremove,
|
|
25
|
+
}: Props = $props()
|
|
26
|
+
|
|
27
|
+
function formatTime(dateString: string): string {
|
|
28
|
+
const date = new Date(dateString)
|
|
29
|
+
const now = new Date()
|
|
30
|
+
|
|
31
|
+
if (date.toDateString() === now.toDateString()) {
|
|
32
|
+
return date.toLocaleTimeString('en-US', {
|
|
33
|
+
hour: 'numeric',
|
|
34
|
+
minute: '2-digit',
|
|
35
|
+
hour12: true,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return date.toLocaleDateString('en-US', {
|
|
40
|
+
month: 'short',
|
|
41
|
+
day: 'numeric',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function truncateContent(content: string, maxLength: number = 150): string {
|
|
46
|
+
if (content.length <= maxLength) return content
|
|
47
|
+
return content.substring(0, maxLength) + '...'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const renderedContent = $derived(
|
|
51
|
+
renderMentionsAsHTML(truncateContent(quotedMessage.content)),
|
|
52
|
+
)
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div class="quoted-message-display {mode} {className}">
|
|
56
|
+
<div
|
|
57
|
+
class="flex items-start gap-2 p-3 bg-base-200/50 rounded-lg border-l-4 border-primary/40 relative"
|
|
58
|
+
>
|
|
59
|
+
{#if mode === 'preview' && onremove}
|
|
60
|
+
<button
|
|
61
|
+
class="absolute top-1 right-1 btn btn-ghost btn-xs btn-circle text-base-content/50 hover:text-error"
|
|
62
|
+
onclick={onremove}
|
|
63
|
+
title="Remove quote"
|
|
64
|
+
>
|
|
65
|
+
✕
|
|
66
|
+
</button>
|
|
67
|
+
{/if}
|
|
68
|
+
|
|
69
|
+
<div class="flex-shrink-0">
|
|
70
|
+
<Avatar
|
|
71
|
+
name={quotedMessage.senderName}
|
|
72
|
+
avatarUrl={quotedMessage.senderAvatar}
|
|
73
|
+
size="xs"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="flex-1 min-w-0">
|
|
78
|
+
<div class="flex items-center gap-2 mb-1">
|
|
79
|
+
<span class="font-medium text-sm text-base-content/80">
|
|
80
|
+
{quotedMessage.senderName}
|
|
81
|
+
</span>
|
|
82
|
+
<span class="text-xs text-base-content/50">
|
|
83
|
+
{formatTime(quotedMessage.createdAt)}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="text-sm text-base-content/70 line-clamp-3 break-words message-content">
|
|
88
|
+
{@html renderedContent}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.line-clamp-3 {
|
|
96
|
+
display: -webkit-box;
|
|
97
|
+
-webkit-line-clamp: 3;
|
|
98
|
+
-webkit-box-orient: vertical;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.quoted-message-display.preview {
|
|
103
|
+
margin-bottom: 8px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.quoted-message-display.inline {
|
|
107
|
+
margin-bottom: 12px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.message-content :global(.mention-link) {
|
|
111
|
+
color: oklch(var(--p) / 0.6);
|
|
112
|
+
transition: color 0.2s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.message-content :global(.mention-link):hover {
|
|
116
|
+
color: oklch(var(--p) / 0.8);
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StartDMModal Component
|
|
3
|
+
|
|
4
|
+
Modal for starting a new direct message conversation.
|
|
5
|
+
Shows list of organization members to message.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { OrgMember } from '../../types/messaging'
|
|
10
|
+
import Avatar from './Avatar.svelte'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
open: boolean
|
|
14
|
+
currentUserId: string
|
|
15
|
+
orgMembers?: OrgMember[]
|
|
16
|
+
onclose?: () => void
|
|
17
|
+
onstartconversation?: (data: { userId: string; userName: string }) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
open = false,
|
|
22
|
+
currentUserId,
|
|
23
|
+
orgMembers = [],
|
|
24
|
+
onclose,
|
|
25
|
+
onstartconversation,
|
|
26
|
+
}: Props = $props()
|
|
27
|
+
|
|
28
|
+
let searchQuery = $state('')
|
|
29
|
+
|
|
30
|
+
const filteredMembers = $derived(
|
|
31
|
+
orgMembers
|
|
32
|
+
.filter((m) => m.id !== currentUserId)
|
|
33
|
+
.filter((m) => !searchQuery || m.full_name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
34
|
+
.sort((a, b) => a.full_name.localeCompare(b.full_name)),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
function handleSelect(member: OrgMember) {
|
|
38
|
+
onstartconversation?.({ userId: member.id, userName: member.full_name })
|
|
39
|
+
onclose?.()
|
|
40
|
+
searchQuery = ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleClose() {
|
|
44
|
+
searchQuery = ''
|
|
45
|
+
onclose?.()
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
{#if open}
|
|
50
|
+
<dialog class="modal modal-open">
|
|
51
|
+
<div class="modal-box max-w-md">
|
|
52
|
+
<div class="flex items-center justify-between mb-4">
|
|
53
|
+
<h3 class="font-bold text-lg">New Message</h3>
|
|
54
|
+
<button class="btn btn-ghost btn-sm btn-circle" onclick={handleClose}>✕</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- Search -->
|
|
58
|
+
<input
|
|
59
|
+
type="text"
|
|
60
|
+
bind:value={searchQuery}
|
|
61
|
+
class="input input-bordered w-full mb-4"
|
|
62
|
+
placeholder="Search team members..."
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
<!-- Members list -->
|
|
66
|
+
<div class="max-h-80 overflow-y-auto space-y-1">
|
|
67
|
+
{#each filteredMembers as member}
|
|
68
|
+
<button
|
|
69
|
+
class="w-full flex items-center gap-3 px-3 py-2 rounded hover:bg-base-200 transition-colors"
|
|
70
|
+
onclick={() => handleSelect(member)}
|
|
71
|
+
>
|
|
72
|
+
<Avatar
|
|
73
|
+
name={member.full_name}
|
|
74
|
+
avatarUrl={member.avatar_url}
|
|
75
|
+
size="small"
|
|
76
|
+
showStatus={true}
|
|
77
|
+
status={member.active ? 'online' : 'offline'}
|
|
78
|
+
/>
|
|
79
|
+
<div class="flex-1 text-left">
|
|
80
|
+
<div class="text-sm font-medium">{member.full_name}</div>
|
|
81
|
+
<div class="text-xs text-base-content/50 capitalize">{member.role}</div>
|
|
82
|
+
</div>
|
|
83
|
+
</button>
|
|
84
|
+
{/each}
|
|
85
|
+
|
|
86
|
+
{#if filteredMembers.length === 0}
|
|
87
|
+
<div class="text-center py-8 text-base-content/50">
|
|
88
|
+
<div class="text-2xl mb-2">💬</div>
|
|
89
|
+
<div class="text-sm">
|
|
90
|
+
{searchQuery ? `No members matching "${searchQuery}"` : 'No team members available'}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<form method="dialog" class="modal-backdrop">
|
|
97
|
+
<button onclick={handleClose}>close</button>
|
|
98
|
+
</form>
|
|
99
|
+
</dialog>
|
|
100
|
+
{/if}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ThreadPanel Component
|
|
3
|
+
|
|
4
|
+
Shows threaded replies to a parent message in a sidebar view.
|
|
5
|
+
All data operations are via callbacks.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type {
|
|
10
|
+
Message,
|
|
11
|
+
PendingAttachment,
|
|
12
|
+
ReactionSummary,
|
|
13
|
+
StandardEmoji,
|
|
14
|
+
MentionItem,
|
|
15
|
+
ThreadCallbacks,
|
|
16
|
+
ReactionCallbacks,
|
|
17
|
+
AttachmentCallbacks,
|
|
18
|
+
} from '../../types/messaging'
|
|
19
|
+
import { renderMentionsAsHTML } from '../../utils/mentionParser'
|
|
20
|
+
import MessageItem from './MessageItem.svelte'
|
|
21
|
+
import MessageInput from './MessageInput.svelte'
|
|
22
|
+
import Avatar from './Avatar.svelte'
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
parentMessage: Message
|
|
26
|
+
replies?: Message[]
|
|
27
|
+
currentUserId: string
|
|
28
|
+
quickReactions?: StandardEmoji[]
|
|
29
|
+
threadCallbacks: ThreadCallbacks
|
|
30
|
+
reactionCallbacks?: ReactionCallbacks
|
|
31
|
+
attachmentCallbacks?: AttachmentCallbacks
|
|
32
|
+
getReactions?: (messageId: string) => ReactionSummary[]
|
|
33
|
+
onsearchmentions?: (query: string) => Promise<MentionItem[]>
|
|
34
|
+
onclose?: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let {
|
|
38
|
+
parentMessage,
|
|
39
|
+
replies = [],
|
|
40
|
+
currentUserId,
|
|
41
|
+
quickReactions = [],
|
|
42
|
+
threadCallbacks,
|
|
43
|
+
reactionCallbacks,
|
|
44
|
+
attachmentCallbacks,
|
|
45
|
+
getReactions,
|
|
46
|
+
onsearchmentions,
|
|
47
|
+
onclose,
|
|
48
|
+
}: Props = $props()
|
|
49
|
+
|
|
50
|
+
let sending = $state(false)
|
|
51
|
+
let messagesContainer: HTMLElement
|
|
52
|
+
|
|
53
|
+
const renderedParentContent = $derived(renderMentionsAsHTML(parentMessage.content))
|
|
54
|
+
|
|
55
|
+
function isConsecutive(msg: Message, index: number): boolean {
|
|
56
|
+
if (index === 0) return false
|
|
57
|
+
const prev = replies[index - 1]
|
|
58
|
+
if (!prev || prev.senderId !== msg.senderId) return false
|
|
59
|
+
const diff = new Date(msg.createdAt).getTime() - new Date(prev.createdAt).getTime()
|
|
60
|
+
return diff < 5 * 60 * 1000
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleSend(data: { content: string; attachments?: PendingAttachment[] }) {
|
|
64
|
+
if (sending) return
|
|
65
|
+
sending = true
|
|
66
|
+
try {
|
|
67
|
+
await threadCallbacks.sendReply(parentMessage.id, data.content, data.attachments)
|
|
68
|
+
scrollToBottom()
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('Failed to send reply:', err)
|
|
71
|
+
} finally {
|
|
72
|
+
sending = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scrollToBottom() {
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
if (messagesContainer) messagesContainer.scrollTop = messagesContainer.scrollHeight
|
|
79
|
+
}, 50)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatTime(dateString: string): string {
|
|
83
|
+
const date = new Date(dateString)
|
|
84
|
+
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<div class="flex flex-col h-full">
|
|
89
|
+
<!-- Header -->
|
|
90
|
+
<div class="flex items-center justify-between px-4 py-3 border-b border-base-300">
|
|
91
|
+
<h3 class="font-semibold text-sm">Thread</h3>
|
|
92
|
+
{#if onclose}
|
|
93
|
+
<button class="btn btn-ghost btn-xs btn-circle" onclick={onclose} title="Close thread">✕</button>
|
|
94
|
+
{/if}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- Parent message -->
|
|
98
|
+
<div class="px-4 py-3 border-b border-base-300 bg-base-200/30">
|
|
99
|
+
<div class="flex items-start gap-3">
|
|
100
|
+
<Avatar name={parentMessage.senderName} avatarUrl={parentMessage.senderAvatar} size="small" />
|
|
101
|
+
<div class="flex-1 min-w-0">
|
|
102
|
+
<div class="flex items-center gap-2 mb-1">
|
|
103
|
+
<span class="font-medium text-sm">{parentMessage.senderName}</span>
|
|
104
|
+
<span class="text-xs text-base-content/50">{formatTime(parentMessage.createdAt)}</span>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="text-sm whitespace-pre-wrap break-words message-content">
|
|
107
|
+
{@html renderedParentContent}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{#if replies.length > 0}
|
|
113
|
+
<div class="mt-2 text-xs text-base-content/50">
|
|
114
|
+
{replies.length} {replies.length === 1 ? 'reply' : 'replies'}
|
|
115
|
+
</div>
|
|
116
|
+
{/if}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- Replies -->
|
|
120
|
+
<div bind:this={messagesContainer} class="flex-1 overflow-y-auto px-4 py-2">
|
|
121
|
+
{#if replies.length === 0}
|
|
122
|
+
<div class="flex flex-col items-center justify-center h-full text-base-content/50">
|
|
123
|
+
<div class="text-2xl mb-2">💬</div>
|
|
124
|
+
<p class="text-sm">No replies yet</p>
|
|
125
|
+
</div>
|
|
126
|
+
{:else}
|
|
127
|
+
{#each replies as reply, index (reply.id)}
|
|
128
|
+
<MessageItem
|
|
129
|
+
message={reply}
|
|
130
|
+
isOwn={reply.senderId === currentUserId}
|
|
131
|
+
isConsecutive={isConsecutive(reply, index)}
|
|
132
|
+
{currentUserId}
|
|
133
|
+
reactions={getReactions?.(reply.id) || []}
|
|
134
|
+
{quickReactions}
|
|
135
|
+
{reactionCallbacks}
|
|
136
|
+
{attachmentCallbacks}
|
|
137
|
+
/>
|
|
138
|
+
{/each}
|
|
139
|
+
{/if}
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Reply input -->
|
|
143
|
+
<div class="px-4 py-3 border-t border-base-300">
|
|
144
|
+
<MessageInput
|
|
145
|
+
placeholder="Reply in thread..."
|
|
146
|
+
compact={true}
|
|
147
|
+
disabled={sending}
|
|
148
|
+
onsend={handleSend}
|
|
149
|
+
{onsearchmentions}
|
|
150
|
+
{attachmentCallbacks}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|