@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,270 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MessageAttachment Component
|
|
3
|
+
|
|
4
|
+
Displays file attachments in messages with preview, lightbox, and actions.
|
|
5
|
+
Uses callback for signed URL retrieval — no direct storage access.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { onMount } from 'svelte'
|
|
10
|
+
import type { MessageAttachment as AttachmentType, AttachmentCallbacks } from '../../types/messaging'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
attachment: AttachmentType
|
|
14
|
+
callbacks: AttachmentCallbacks
|
|
15
|
+
currentUserId?: string
|
|
16
|
+
showDelete?: boolean
|
|
17
|
+
compact?: boolean
|
|
18
|
+
class?: string
|
|
19
|
+
ondelete?: (attachmentId: string) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
attachment,
|
|
24
|
+
callbacks,
|
|
25
|
+
currentUserId,
|
|
26
|
+
showDelete = false,
|
|
27
|
+
compact = false,
|
|
28
|
+
class: className = '',
|
|
29
|
+
ondelete,
|
|
30
|
+
}: Props = $props()
|
|
31
|
+
|
|
32
|
+
let signedUrl = $state<string | null>(null)
|
|
33
|
+
let thumbnailUrl = $state<string | null>(null)
|
|
34
|
+
let loading = $state(true)
|
|
35
|
+
let error = $state<string | null>(null)
|
|
36
|
+
let showContextMenu = $state(false)
|
|
37
|
+
let showLightbox = $state(false)
|
|
38
|
+
let showDetailsModal = $state(false)
|
|
39
|
+
let contextMenuPosition = $state({ x: 0, y: 0 })
|
|
40
|
+
|
|
41
|
+
function isImage(): boolean { return attachment.file_type.startsWith('image/') }
|
|
42
|
+
function isVideo(): boolean { return attachment.file_type.startsWith('video/') }
|
|
43
|
+
|
|
44
|
+
function formatFileSize(bytes: number): string {
|
|
45
|
+
if (bytes === 0) return '0 B'
|
|
46
|
+
const k = 1024
|
|
47
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
48
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
49
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatDuration(seconds: number): string {
|
|
53
|
+
const mins = Math.floor(seconds / 60)
|
|
54
|
+
const secs = seconds % 60
|
|
55
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatDate(dateString: string): string {
|
|
59
|
+
return new Date(dateString).toLocaleDateString('en-US', {
|
|
60
|
+
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadSignedUrls() {
|
|
65
|
+
loading = true
|
|
66
|
+
error = null
|
|
67
|
+
try {
|
|
68
|
+
signedUrl = await callbacks.getSignedUrl(attachment.storage_path)
|
|
69
|
+
if (attachment.thumbnail_path) {
|
|
70
|
+
thumbnailUrl = await callbacks.getThumbnailUrl(attachment.thumbnail_path)
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
error = 'Failed to load file'
|
|
74
|
+
} finally {
|
|
75
|
+
loading = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleContextMenu(e: MouseEvent) {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
contextMenuPosition = { x: e.clientX, y: e.clientY }
|
|
82
|
+
showContextMenu = true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function closeContextMenu() { showContextMenu = false }
|
|
86
|
+
|
|
87
|
+
async function handleOpenInNewTab() {
|
|
88
|
+
closeContextMenu()
|
|
89
|
+
if (signedUrl) window.open(signedUrl, '_blank')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleDownload() {
|
|
93
|
+
closeContextMenu()
|
|
94
|
+
if (!signedUrl) return
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(signedUrl)
|
|
97
|
+
const blob = await response.blob()
|
|
98
|
+
const url = URL.createObjectURL(blob)
|
|
99
|
+
const a = document.createElement('a')
|
|
100
|
+
a.href = url
|
|
101
|
+
a.download = attachment.file_name
|
|
102
|
+
document.body.appendChild(a)
|
|
103
|
+
a.click()
|
|
104
|
+
document.body.removeChild(a)
|
|
105
|
+
URL.revokeObjectURL(url)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('Download failed:', err)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleCopyLink() {
|
|
112
|
+
closeContextMenu()
|
|
113
|
+
if (signedUrl) {
|
|
114
|
+
try { await navigator.clipboard.writeText(signedUrl) } catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleDelete() {
|
|
119
|
+
closeContextMenu()
|
|
120
|
+
if (confirm(`Delete "${attachment.file_name}"? This cannot be undone.`)) {
|
|
121
|
+
ondelete?.(attachment.id)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleClick() {
|
|
126
|
+
if (isImage() || isVideo()) showLightbox = true
|
|
127
|
+
else handleOpenInNewTab()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleWindowClick() { if (showContextMenu) closeContextMenu() }
|
|
131
|
+
|
|
132
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
133
|
+
if (e.key === 'Escape') {
|
|
134
|
+
showLightbox = false
|
|
135
|
+
showDetailsModal = false
|
|
136
|
+
showContextMenu = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMount(() => {
|
|
141
|
+
loadSignedUrls()
|
|
142
|
+
window.addEventListener('click', handleWindowClick)
|
|
143
|
+
window.addEventListener('keydown', handleKeydown)
|
|
144
|
+
return () => {
|
|
145
|
+
window.removeEventListener('click', handleWindowClick)
|
|
146
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const canDelete = $derived(showDelete && currentUserId === attachment.uploaded_by)
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<div class="relative group {className}" oncontextmenu={handleContextMenu}>
|
|
154
|
+
{#if loading}
|
|
155
|
+
<div class="flex items-center justify-center bg-base-200 rounded-lg {compact ? 'w-16 h-16' : 'w-48 h-32'}">
|
|
156
|
+
<span class="loading loading-spinner loading-sm"></span>
|
|
157
|
+
</div>
|
|
158
|
+
{:else if error}
|
|
159
|
+
<div class="flex items-center justify-center bg-error/10 rounded-lg border border-error/30 {compact ? 'w-16 h-16' : 'w-48 h-32'}" title={error}>
|
|
160
|
+
<span class="text-error text-lg">📄</span>
|
|
161
|
+
</div>
|
|
162
|
+
{:else if isImage()}
|
|
163
|
+
<button type="button" class="block overflow-hidden rounded-lg border border-base-300 hover:border-primary transition-colors cursor-pointer" onclick={handleClick}>
|
|
164
|
+
<img src={signedUrl} alt={attachment.file_name} class={compact ? 'w-16 h-16 object-cover' : 'max-w-[300px] max-h-[200px] object-contain'} loading="lazy" />
|
|
165
|
+
</button>
|
|
166
|
+
{:else if isVideo()}
|
|
167
|
+
<button type="button" class="block relative overflow-hidden rounded-lg border border-base-300 hover:border-primary transition-colors cursor-pointer {compact ? 'w-16 h-16' : 'w-48 h-32'}" onclick={handleClick}>
|
|
168
|
+
{#if thumbnailUrl}
|
|
169
|
+
<img src={thumbnailUrl} alt={attachment.file_name} class="w-full h-full object-cover" />
|
|
170
|
+
{:else}
|
|
171
|
+
<div class="w-full h-full bg-base-200 flex items-center justify-center">
|
|
172
|
+
<span class="text-2xl">🎬</span>
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
<div class="absolute inset-0 flex items-center justify-center bg-black/20">
|
|
176
|
+
<div class="w-10 h-10 rounded-full bg-white/90 flex items-center justify-center">
|
|
177
|
+
<span class="ml-0.5">▶</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
{#if attachment.duration}
|
|
181
|
+
<div class="absolute bottom-1 right-1 px-1 py-0.5 bg-black/70 rounded text-xs text-white">
|
|
182
|
+
{formatDuration(attachment.duration)}
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
</button>
|
|
186
|
+
{:else}
|
|
187
|
+
<button type="button" class="flex items-center gap-2 p-3 bg-base-200 rounded-lg border border-base-300 hover:border-primary transition-colors cursor-pointer {compact ? 'w-full' : 'min-w-[200px] max-w-[300px]'}" onclick={handleClick}>
|
|
188
|
+
<span class="text-2xl flex-shrink-0">📄</span>
|
|
189
|
+
<div class="flex-1 min-w-0 text-left">
|
|
190
|
+
<p class="text-sm font-medium truncate" title={attachment.file_name}>{attachment.file_name}</p>
|
|
191
|
+
<p class="text-xs text-base-content/60">{formatFileSize(attachment.file_size)}</p>
|
|
192
|
+
</div>
|
|
193
|
+
<span class="text-base-content/40 flex-shrink-0">↗</span>
|
|
194
|
+
</button>
|
|
195
|
+
{/if}
|
|
196
|
+
|
|
197
|
+
{#if !compact}
|
|
198
|
+
<button type="button" class="absolute top-1 right-1 btn btn-circle btn-xs bg-base-100/80 backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity" onclick={(e) => { e.stopPropagation(); handleContextMenu(e) }} title="More options">
|
|
199
|
+
⋮
|
|
200
|
+
</button>
|
|
201
|
+
{/if}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Context Menu -->
|
|
205
|
+
{#if showContextMenu}
|
|
206
|
+
<div class="fixed z-50 bg-base-100 rounded-lg shadow-xl border border-base-300 py-1 min-w-[160px]" style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;" onclick={(e) => e.stopPropagation()} role="menu">
|
|
207
|
+
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleOpenInNewTab} role="menuitem">↗ Open in new tab</button>
|
|
208
|
+
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleDownload} role="menuitem">⬇ Download</button>
|
|
209
|
+
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleCopyLink} role="menuitem">📋 Copy link</button>
|
|
210
|
+
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={() => { closeContextMenu(); showDetailsModal = true }} role="menuitem">ℹ View details</button>
|
|
211
|
+
{#if canDelete}
|
|
212
|
+
<div class="border-t border-base-300 my-1"></div>
|
|
213
|
+
<button class="w-full flex items-center gap-2 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors" onclick={handleDelete} role="menuitem">🗑 Delete</button>
|
|
214
|
+
{/if}
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
<!-- Lightbox Modal -->
|
|
219
|
+
{#if showLightbox && signedUrl}
|
|
220
|
+
<dialog class="modal modal-open">
|
|
221
|
+
<div class="modal-box max-w-4xl w-full max-h-[90vh] p-0 bg-black">
|
|
222
|
+
<button class="btn btn-circle btn-ghost btn-sm absolute top-2 right-2 z-10 text-white" onclick={() => (showLightbox = false)}>✕</button>
|
|
223
|
+
<div class="flex items-center justify-center min-h-[50vh]">
|
|
224
|
+
{#if isImage()}
|
|
225
|
+
<img src={signedUrl} alt={attachment.file_name} class="max-w-full max-h-[85vh] object-contain" />
|
|
226
|
+
{:else if isVideo()}
|
|
227
|
+
<video src={signedUrl} class="max-w-full max-h-[85vh]" controls autoplay><track kind="captions" /></video>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
<div class="absolute bottom-0 left-0 right-0 bg-black/80 px-4 py-2 flex items-center justify-between text-white text-sm">
|
|
231
|
+
<span class="truncate">{attachment.file_name}</span>
|
|
232
|
+
<div class="flex items-center gap-4">
|
|
233
|
+
<span class="text-white/60">{formatFileSize(attachment.file_size)}</span>
|
|
234
|
+
<button class="btn btn-ghost btn-xs text-white" onclick={handleDownload}>⬇ Download</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
<form method="dialog" class="modal-backdrop bg-black/80">
|
|
239
|
+
<button onclick={() => (showLightbox = false)}>close</button>
|
|
240
|
+
</form>
|
|
241
|
+
</dialog>
|
|
242
|
+
{/if}
|
|
243
|
+
|
|
244
|
+
<!-- Details Modal -->
|
|
245
|
+
{#if showDetailsModal}
|
|
246
|
+
<dialog class="modal modal-open">
|
|
247
|
+
<div class="modal-box max-w-md">
|
|
248
|
+
<h3 class="font-bold text-lg mb-4">File Details</h3>
|
|
249
|
+
<div class="space-y-3 text-sm">
|
|
250
|
+
<div class="flex justify-between"><span class="text-base-content/60">Name</span><span class="font-medium truncate max-w-[200px]" title={attachment.file_name}>{attachment.file_name}</span></div>
|
|
251
|
+
<div class="flex justify-between"><span class="text-base-content/60">Type</span><span class="font-medium">{attachment.file_type}</span></div>
|
|
252
|
+
<div class="flex justify-between"><span class="text-base-content/60">Size</span><span class="font-medium">{formatFileSize(attachment.file_size)}</span></div>
|
|
253
|
+
{#if attachment.width && attachment.height}
|
|
254
|
+
<div class="flex justify-between"><span class="text-base-content/60">Dimensions</span><span class="font-medium">{attachment.width} × {attachment.height}</span></div>
|
|
255
|
+
{/if}
|
|
256
|
+
{#if attachment.duration}
|
|
257
|
+
<div class="flex justify-between"><span class="text-base-content/60">Duration</span><span class="font-medium">{formatDuration(attachment.duration)}</span></div>
|
|
258
|
+
{/if}
|
|
259
|
+
<div class="flex justify-between"><span class="text-base-content/60">Uploaded</span><span class="font-medium">{formatDate(attachment.created_at)}</span></div>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="modal-action">
|
|
262
|
+
<button class="btn btn-ghost" onclick={() => (showDetailsModal = false)}>Close</button>
|
|
263
|
+
<button class="btn btn-primary" onclick={handleDownload}>⬇ Download</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<form method="dialog" class="modal-backdrop">
|
|
267
|
+
<button onclick={() => (showDetailsModal = false)}>close</button>
|
|
268
|
+
</form>
|
|
269
|
+
</dialog>
|
|
270
|
+
{/if}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MessageAttachmentUpload Component
|
|
3
|
+
|
|
4
|
+
Handles file selection and preview for message attachments.
|
|
5
|
+
Upload is delegated to callbacks — no direct storage access.
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { PendingAttachment, AttachmentCallbacks } from '../../types/messaging'
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
callbacks: AttachmentCallbacks
|
|
13
|
+
organizationId: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
maxSize?: number
|
|
16
|
+
maxFiles?: number
|
|
17
|
+
attachments?: PendingAttachment[]
|
|
18
|
+
onattachmentschange?: (attachments: PendingAttachment[]) => void
|
|
19
|
+
onuploadcomplete?: (attachments: PendingAttachment[]) => void
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
callbacks,
|
|
25
|
+
organizationId,
|
|
26
|
+
disabled = false,
|
|
27
|
+
maxSize = 25,
|
|
28
|
+
maxFiles = 10,
|
|
29
|
+
attachments = $bindable([]),
|
|
30
|
+
onattachmentschange,
|
|
31
|
+
onuploadcomplete,
|
|
32
|
+
class: className = '',
|
|
33
|
+
}: Props = $props()
|
|
34
|
+
|
|
35
|
+
let isDragging = $state(false)
|
|
36
|
+
let fileInputRef = $state<HTMLInputElement | null>(null)
|
|
37
|
+
let dropZoneRef = $state<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
const acceptedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
|
40
|
+
const acceptedVideoTypes = ['video/mp4', 'video/webm', 'video/quicktime']
|
|
41
|
+
const acceptedDocTypes = ['application/pdf', 'application/msword', 'text/plain']
|
|
42
|
+
const allAcceptedTypes = [...acceptedImageTypes, ...acceptedVideoTypes, ...acceptedDocTypes]
|
|
43
|
+
|
|
44
|
+
function isImage(file: File): boolean { return acceptedImageTypes.includes(file.type) }
|
|
45
|
+
function isVideo(file: File): boolean { return acceptedVideoTypes.includes(file.type) }
|
|
46
|
+
|
|
47
|
+
function formatFileSize(bytes: number): string {
|
|
48
|
+
if (bytes === 0) return '0 B'
|
|
49
|
+
const k = 1024
|
|
50
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
51
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
52
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function generatePreview(file: File): Promise<string | undefined> {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
if (isImage(file)) {
|
|
58
|
+
const reader = new FileReader()
|
|
59
|
+
reader.onload = (e) => resolve(e.target?.result as string)
|
|
60
|
+
reader.onerror = () => resolve(undefined)
|
|
61
|
+
reader.readAsDataURL(file)
|
|
62
|
+
} else if (isVideo(file)) {
|
|
63
|
+
const video = document.createElement('video')
|
|
64
|
+
video.preload = 'metadata'
|
|
65
|
+
video.onloadeddata = () => { video.currentTime = 1 }
|
|
66
|
+
video.onseeked = () => {
|
|
67
|
+
const canvas = document.createElement('canvas')
|
|
68
|
+
canvas.width = video.videoWidth
|
|
69
|
+
canvas.height = video.videoHeight
|
|
70
|
+
const ctx = canvas.getContext('2d')
|
|
71
|
+
if (ctx) {
|
|
72
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
|
73
|
+
resolve(canvas.toDataURL('image/jpeg'))
|
|
74
|
+
} else resolve(undefined)
|
|
75
|
+
URL.revokeObjectURL(video.src)
|
|
76
|
+
}
|
|
77
|
+
video.onerror = () => { resolve(undefined); URL.revokeObjectURL(video.src) }
|
|
78
|
+
video.src = URL.createObjectURL(file)
|
|
79
|
+
} else resolve(undefined)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getImageDimensions(file: File): Promise<{ width: number; height: number } | undefined> {
|
|
84
|
+
if (!isImage(file)) return undefined
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const img = new window.Image()
|
|
87
|
+
img.onload = () => { resolve({ width: img.width, height: img.height }); URL.revokeObjectURL(img.src) }
|
|
88
|
+
img.onerror = () => { resolve(undefined); URL.revokeObjectURL(img.src) }
|
|
89
|
+
img.src = URL.createObjectURL(file)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function processFiles(files: FileList | File[]) {
|
|
94
|
+
if (disabled) return
|
|
95
|
+
const fileArray = Array.from(files)
|
|
96
|
+
if (attachments.length + fileArray.length > maxFiles) return
|
|
97
|
+
|
|
98
|
+
const newAttachments: PendingAttachment[] = []
|
|
99
|
+
for (const file of fileArray) {
|
|
100
|
+
if (!allAcceptedTypes.includes(file.type)) continue
|
|
101
|
+
if (file.size > maxSize * 1024 * 1024) continue
|
|
102
|
+
|
|
103
|
+
const preview = await generatePreview(file)
|
|
104
|
+
const dimensions = await getImageDimensions(file)
|
|
105
|
+
|
|
106
|
+
newAttachments.push({
|
|
107
|
+
id: crypto.randomUUID(),
|
|
108
|
+
file,
|
|
109
|
+
preview,
|
|
110
|
+
uploading: false,
|
|
111
|
+
progress: 0,
|
|
112
|
+
width: dimensions?.width,
|
|
113
|
+
height: dimensions?.height,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (newAttachments.length > 0) {
|
|
118
|
+
attachments = [...attachments, ...newAttachments]
|
|
119
|
+
onattachmentschange?.(attachments)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function removeAttachment(id: string) {
|
|
124
|
+
attachments = attachments.filter((a) => a.id !== id)
|
|
125
|
+
onattachmentschange?.(attachments)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function uploadAll(): Promise<PendingAttachment[]> {
|
|
129
|
+
if (attachments.length === 0) return []
|
|
130
|
+
const uploadedAttachments: PendingAttachment[] = []
|
|
131
|
+
|
|
132
|
+
for (const attachment of attachments) {
|
|
133
|
+
if (attachment.storagePath) { uploadedAttachments.push(attachment); continue }
|
|
134
|
+
|
|
135
|
+
attachment.uploading = true
|
|
136
|
+
attachments = [...attachments]
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = await callbacks.uploadFile(attachment.file, organizationId)
|
|
140
|
+
if (result.error) throw new Error(result.error)
|
|
141
|
+
attachment.storagePath = result.path
|
|
142
|
+
attachment.uploading = false
|
|
143
|
+
attachment.progress = 100
|
|
144
|
+
uploadedAttachments.push(attachment)
|
|
145
|
+
} catch (err) {
|
|
146
|
+
attachment.uploading = false
|
|
147
|
+
attachment.error = err instanceof Error ? err.message : 'Upload failed'
|
|
148
|
+
}
|
|
149
|
+
attachments = [...attachments]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onuploadcomplete?.(uploadedAttachments)
|
|
153
|
+
return uploadedAttachments
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function clear() {
|
|
157
|
+
attachments = []
|
|
158
|
+
onattachmentschange?.(attachments)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function hasPendingUploads(): boolean {
|
|
162
|
+
return attachments.some((a) => !a.storagePath && !a.error)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleDragEnter(e: DragEvent) { e.preventDefault(); e.stopPropagation(); isDragging = true }
|
|
166
|
+
function handleDragLeave(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (e.target === dropZoneRef) isDragging = false }
|
|
167
|
+
function handleDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); isDragging = true }
|
|
168
|
+
|
|
169
|
+
async function handleDrop(e: DragEvent) {
|
|
170
|
+
e.preventDefault(); e.stopPropagation(); isDragging = false
|
|
171
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) await processFiles(e.dataTransfer.files)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleFileSelect(e: Event) {
|
|
175
|
+
const input = e.target as HTMLInputElement
|
|
176
|
+
if (input.files && input.files.length > 0) processFiles(input.files)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function openFilePicker() { fileInputRef?.click() }
|
|
180
|
+
function getFileTypeIcon(file: File): string { if (isImage(file)) return '🖼'; if (isVideo(file)) return '🎬'; return '📄' }
|
|
181
|
+
|
|
182
|
+
const hasAttachments = $derived(attachments.length > 0)
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<input bind:this={fileInputRef} type="file" accept={allAcceptedTypes.join(',')} multiple class="hidden" onchange={handleFileSelect} {disabled} />
|
|
186
|
+
|
|
187
|
+
<div class={className}>
|
|
188
|
+
{#if isDragging}
|
|
189
|
+
<div
|
|
190
|
+
bind:this={dropZoneRef}
|
|
191
|
+
class="fixed inset-0 z-50 bg-primary/10 backdrop-blur-sm flex items-center justify-center"
|
|
192
|
+
ondragenter={handleDragEnter} ondragleave={handleDragLeave} ondragover={handleDragOver} ondrop={handleDrop}
|
|
193
|
+
role="region" aria-label="Drop zone for file uploads"
|
|
194
|
+
>
|
|
195
|
+
<div class="bg-base-100 rounded-2xl border-2 border-dashed border-primary p-8 text-center shadow-xl">
|
|
196
|
+
<div class="text-4xl mb-4">📎</div>
|
|
197
|
+
<p class="text-lg font-medium text-primary">Drop files to upload</p>
|
|
198
|
+
<p class="text-sm text-base-content/60 mt-2">Images, videos, and documents up to {maxSize}MB</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{/if}
|
|
202
|
+
|
|
203
|
+
{#if hasAttachments}
|
|
204
|
+
<div class="flex flex-wrap gap-2 p-2 bg-base-200 rounded-lg mb-2">
|
|
205
|
+
{#each attachments as att (att.id)}
|
|
206
|
+
<div class="relative group bg-base-100 rounded-lg overflow-hidden border border-base-300 {att.error ? 'border-error' : ''}">
|
|
207
|
+
<div class="w-20 h-20 flex items-center justify-center">
|
|
208
|
+
{#if att.preview}
|
|
209
|
+
<img src={att.preview} alt={att.file.name} class="w-full h-full object-cover" />
|
|
210
|
+
{:else}
|
|
211
|
+
<span class="text-2xl">{getFileTypeIcon(att.file)}</span>
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{#if att.uploading}
|
|
216
|
+
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
217
|
+
<span class="loading loading-spinner loading-sm text-white"></span>
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
|
|
221
|
+
{#if att.error}
|
|
222
|
+
<div class="absolute inset-0 bg-error/80 flex items-center justify-center" title={att.error}>
|
|
223
|
+
<span class="text-error-content text-lg">⚠</span>
|
|
224
|
+
</div>
|
|
225
|
+
{/if}
|
|
226
|
+
|
|
227
|
+
<button type="button" class="absolute top-1 right-1 btn btn-circle btn-xs bg-base-100/80 hover:bg-error hover:text-error-content opacity-0 group-hover:opacity-100 transition-opacity" onclick={() => removeAttachment(att.id)} title="Remove">✕</button>
|
|
228
|
+
|
|
229
|
+
<div class="absolute bottom-0 left-0 right-0 bg-base-100/90 px-1 py-0.5 text-xs truncate" title={att.file.name}>
|
|
230
|
+
{att.file.name.length > 12 ? att.file.name.slice(0, 10) + '...' : att.file.name}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
{/each}
|
|
234
|
+
|
|
235
|
+
{#if attachments.length < maxFiles}
|
|
236
|
+
<button type="button" class="w-20 h-20 flex flex-col items-center justify-center border-2 border-dashed border-base-300 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors" onclick={openFilePicker} {disabled}>
|
|
237
|
+
<span class="text-base-content/50">+</span>
|
|
238
|
+
<span class="text-xs text-base-content/50 mt-1">Add</span>
|
|
239
|
+
</button>
|
|
240
|
+
{/if}
|
|
241
|
+
</div>
|
|
242
|
+
{/if}
|
|
243
|
+
</div>
|