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