@joewinke/jatui 0.1.10 → 0.1.19

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 (91) hide show
  1. package/README.md +123 -0
  2. package/package.json +3 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +188 -0
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +183 -63
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +105 -1
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/phone.ts +80 -0
  91. package/src/lib/utils/taskUtils.ts +21 -7
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import type { Annotation, AnnotationThread } from '../types/annotation'
3
+
4
+ interface Props {
5
+ annotations: Annotation[]
6
+ activeAnnotationId?: string | null
7
+ annotationMode?: boolean
8
+ showResolved?: boolean
9
+ onAnnotationClick?: (annotation: Annotation) => void
10
+ onImageClick?: (x: number, y: number) => void
11
+ }
12
+
13
+ let {
14
+ annotations = [],
15
+ activeAnnotationId = null,
16
+ annotationMode = false,
17
+ showResolved = false,
18
+ onAnnotationClick,
19
+ onImageClick,
20
+ }: Props = $props()
21
+
22
+ // Group into threads (only show root pins)
23
+ const threads = $derived.by(() => {
24
+ const roots = annotations.filter((a) => !a.parent_annotation_id)
25
+ return roots
26
+ .filter((r) => showResolved || !r.resolved)
27
+ .map((root) => ({
28
+ root,
29
+ replies: annotations.filter((a) => a.parent_annotation_id === root.id),
30
+ }))
31
+ })
32
+
33
+ function handleOverlayClick(e: MouseEvent) {
34
+ if (!annotationMode || !onImageClick) return
35
+ const target = e.currentTarget as HTMLElement
36
+ const rect = target.getBoundingClientRect()
37
+ const x = ((e.clientX - rect.left) / rect.width) * 100
38
+ const y = ((e.clientY - rect.top) / rect.height) * 100
39
+ onImageClick(Math.round(x * 1000) / 1000, Math.round(y * 1000) / 1000)
40
+ }
41
+ </script>
42
+
43
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
44
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
45
+ <div
46
+ class="absolute inset-0 z-10"
47
+ class:cursor-crosshair={annotationMode}
48
+ class:pointer-events-none={!annotationMode && threads.length === 0}
49
+ onclick={handleOverlayClick}
50
+ >
51
+ {#each threads as thread (thread.root.id)}
52
+ {@const isActive = activeAnnotationId === thread.root.id}
53
+ {@const replyCount = thread.replies.length}
54
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
55
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
56
+ <div
57
+ class="absolute pointer-events-auto"
58
+ style="left: {thread.root.x_percent}%; top: {thread.root.y_percent}%; transform: translate(-50%, -100%);"
59
+ >
60
+ <button
61
+ class="relative group flex flex-col items-center"
62
+ onclick={(e) => { e.stopPropagation(); onAnnotationClick?.(thread.root) }}
63
+ title={thread.root.content.slice(0, 80)}
64
+ >
65
+ <!-- Pin icon -->
66
+ <div
67
+ class="w-7 h-7 rounded-full flex items-center justify-center shadow-lg border-2 transition-all
68
+ {isActive
69
+ ? 'bg-primary border-primary text-primary-content scale-125'
70
+ : thread.root.resolved
71
+ ? 'bg-success/80 border-success/60 text-success-content'
72
+ : 'bg-warning border-warning/80 text-warning-content hover:scale-110'}"
73
+ >
74
+ {#if thread.root.resolved}
75
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
76
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
77
+ </svg>
78
+ {:else}
79
+ <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
80
+ <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" />
81
+ </svg>
82
+ {/if}
83
+ </div>
84
+
85
+ <!-- Reply count badge -->
86
+ {#if replyCount > 0}
87
+ <span class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-base-content text-base-100 text-[10px] font-bold flex items-center justify-center">
88
+ {replyCount}
89
+ </span>
90
+ {/if}
91
+
92
+ <!-- Pin tail -->
93
+ <div
94
+ class="w-0.5 h-2 -mt-0.5 {isActive ? 'bg-primary' : thread.root.resolved ? 'bg-success/60' : 'bg-warning/80'}"
95
+ ></div>
96
+ </button>
97
+ </div>
98
+ {/each}
99
+
100
+ <!-- Placement indicator when in annotation mode -->
101
+ {#if annotationMode}
102
+ <div class="absolute inset-0 border-2 border-dashed border-primary/30 rounded pointer-events-none">
103
+ <div class="absolute top-2 left-1/2 -translate-x-1/2 bg-primary/90 text-primary-content text-xs px-3 py-1 rounded-full shadow whitespace-nowrap">
104
+ Click to place annotation
105
+ </div>
106
+ </div>
107
+ {/if}
108
+ </div>
@@ -0,0 +1,319 @@
1
+ <script lang="ts">
2
+ import type { Annotation, AnnotationProfile, AnnotationCallbacks } from '../types/annotation'
3
+
4
+ interface Props {
5
+ annotations: Annotation[]
6
+ profiles: Record<string, AnnotationProfile>
7
+ activeAnnotationId?: string | null
8
+ currentUserId: string
9
+ callbacks: AnnotationCallbacks
10
+ onAnnotationSelect?: (annotation: Annotation | null) => void
11
+ onAnnotationsChanged?: () => void
12
+ }
13
+
14
+ let {
15
+ annotations = [],
16
+ profiles = {},
17
+ activeAnnotationId = null,
18
+ currentUserId,
19
+ callbacks,
20
+ onAnnotationSelect,
21
+ onAnnotationsChanged,
22
+ }: Props = $props()
23
+
24
+ let replyContent = $state("")
25
+ let isSubmitting = $state(false)
26
+ let showResolved = $state(false)
27
+ let editingId = $state<string | null>(null)
28
+ let editContent = $state("")
29
+
30
+ // Group annotations into threads
31
+ const threads = $derived.by(() => {
32
+ const roots = annotations.filter((a) => !a.parent_annotation_id)
33
+ return roots
34
+ .filter((r) => showResolved || !r.resolved)
35
+ .map((root) => ({
36
+ root,
37
+ replies: annotations
38
+ .filter((a) => a.parent_annotation_id === root.id)
39
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
40
+ }))
41
+ .sort((a, b) => new Date(b.root.created_at).getTime() - new Date(a.root.created_at).getTime())
42
+ })
43
+
44
+ const resolvedCount = $derived(
45
+ annotations.filter((a) => !a.parent_annotation_id && a.resolved).length,
46
+ )
47
+ const totalRoots = $derived(annotations.filter((a) => !a.parent_annotation_id).length)
48
+
49
+ function getAuthorName(userId: string): string {
50
+ return profiles[userId]?.full_name || "Unknown"
51
+ }
52
+
53
+ function getInitials(userId: string): string {
54
+ const name = getAuthorName(userId)
55
+ return name
56
+ .split(" ")
57
+ .map((n) => n[0])
58
+ .join("")
59
+ .toUpperCase()
60
+ .slice(0, 2)
61
+ }
62
+
63
+ function formatTime(dateStr: string): string {
64
+ const date = new Date(dateStr)
65
+ const now = new Date()
66
+ const diffMs = now.getTime() - date.getTime()
67
+ const diffMins = Math.floor(diffMs / 60000)
68
+ if (diffMins < 1) return "just now"
69
+ if (diffMins < 60) return `${diffMins}m ago`
70
+ const diffHours = Math.floor(diffMins / 60)
71
+ if (diffHours < 24) return `${diffHours}h ago`
72
+ const diffDays = Math.floor(diffHours / 24)
73
+ if (diffDays < 7) return `${diffDays}d ago`
74
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
75
+ }
76
+
77
+ async function submitReply(parentId: string) {
78
+ if (!replyContent.trim() || isSubmitting) return
79
+ isSubmitting = true
80
+ try {
81
+ await callbacks.onAddReply(parentId, replyContent.trim())
82
+ replyContent = ""
83
+ onAnnotationsChanged?.()
84
+ } catch (err) {
85
+ console.error("Error posting reply:", err)
86
+ } finally {
87
+ isSubmitting = false
88
+ }
89
+ }
90
+
91
+ async function toggleResolve(annotation: Annotation) {
92
+ try {
93
+ await callbacks.onResolve(annotation.id, !annotation.resolved)
94
+ onAnnotationsChanged?.()
95
+ } catch (err) {
96
+ console.error("Error updating annotation:", err)
97
+ }
98
+ }
99
+
100
+ async function deleteAnnotation(id: string) {
101
+ try {
102
+ await callbacks.onDelete(id)
103
+ if (activeAnnotationId === id) onAnnotationSelect?.(null)
104
+ onAnnotationsChanged?.()
105
+ } catch (err) {
106
+ console.error("Error deleting annotation:", err)
107
+ }
108
+ }
109
+
110
+ async function saveEdit(id: string) {
111
+ if (!editContent.trim() || isSubmitting) return
112
+ isSubmitting = true
113
+ try {
114
+ await callbacks.onEdit(id, editContent.trim())
115
+ editingId = null
116
+ editContent = ""
117
+ onAnnotationsChanged?.()
118
+ } catch (err) {
119
+ console.error("Error editing annotation:", err)
120
+ } finally {
121
+ isSubmitting = false
122
+ }
123
+ }
124
+
125
+ function startEdit(annotation: Annotation) {
126
+ editingId = annotation.id
127
+ editContent = annotation.content
128
+ }
129
+ </script>
130
+
131
+ <div class="flex flex-col h-full">
132
+ <!-- Header -->
133
+ <div class="flex items-center justify-between px-3 py-2 border-b border-base-300 flex-shrink-0">
134
+ <div class="flex items-center gap-2">
135
+ <h4 class="text-sm font-semibold">Annotations</h4>
136
+ {#if totalRoots > 0}
137
+ <span class="badge badge-xs badge-ghost">{totalRoots - resolvedCount} open</span>
138
+ {/if}
139
+ </div>
140
+ {#if resolvedCount > 0}
141
+ <label class="flex items-center gap-1.5 cursor-pointer">
142
+ <span class="text-xs text-base-content/50">Show resolved</span>
143
+ <input
144
+ type="checkbox"
145
+ class="toggle toggle-xs"
146
+ bind:checked={showResolved}
147
+ />
148
+ </label>
149
+ {/if}
150
+ </div>
151
+
152
+ <!-- Thread list -->
153
+ <div class="flex-1 overflow-y-auto">
154
+ {#if threads.length === 0}
155
+ <div class="text-center py-8 px-4">
156
+ <svg class="mx-auto h-10 w-10 text-base-content/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
157
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
158
+ </svg>
159
+ <p class="text-sm text-base-content/40 mt-2">No annotations yet</p>
160
+ <p class="text-xs text-base-content/30 mt-1">Click the pin icon then click on the image to add one</p>
161
+ </div>
162
+ {:else}
163
+ {#each threads as thread (thread.root.id)}
164
+ {@const isActive = activeAnnotationId === thread.root.id}
165
+ <div
166
+ class="border-b border-base-200 transition-colors {isActive ? 'bg-primary/5' : 'hover:bg-base-200/50'}"
167
+ >
168
+ <!-- Root annotation -->
169
+ <button
170
+ class="w-full text-left px-3 py-2.5"
171
+ onclick={() => onAnnotationSelect?.(isActive ? null : thread.root)}
172
+ >
173
+ <div class="flex items-start gap-2">
174
+ <!-- Avatar -->
175
+ <div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold mt-0.5">
176
+ {getInitials(thread.root.user_id)}
177
+ </div>
178
+ <div class="flex-1 min-w-0">
179
+ <div class="flex items-center gap-1.5">
180
+ <span class="text-xs font-medium text-base-content">{getAuthorName(thread.root.user_id)}</span>
181
+ <span class="text-[10px] text-base-content/40">{formatTime(thread.root.created_at)}</span>
182
+ {#if thread.root.resolved}
183
+ <span class="badge badge-xs badge-success gap-0.5">
184
+ <svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
185
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
186
+ </svg>
187
+ Resolved
188
+ </span>
189
+ {/if}
190
+ </div>
191
+ <p class="text-sm text-base-content/80 mt-0.5 {isActive ? '' : 'line-clamp-2'}">{thread.root.content}</p>
192
+ {#if thread.replies.length > 0 && !isActive}
193
+ <span class="text-xs text-base-content/40 mt-1 inline-block">
194
+ {thread.replies.length} {thread.replies.length === 1 ? "reply" : "replies"}
195
+ </span>
196
+ {/if}
197
+ </div>
198
+ </div>
199
+ </button>
200
+
201
+ <!-- Expanded thread -->
202
+ {#if isActive}
203
+ <div class="px-3 pb-3 space-y-2">
204
+ <!-- Actions for root -->
205
+ <div class="flex items-center gap-1 ml-8">
206
+ <button
207
+ class="btn btn-ghost btn-xs gap-1 {thread.root.resolved ? 'text-warning' : 'text-success'}"
208
+ onclick={() => toggleResolve(thread.root)}
209
+ >
210
+ {#if thread.root.resolved}
211
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
212
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
213
+ </svg>
214
+ Reopen
215
+ {:else}
216
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
217
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
218
+ </svg>
219
+ Resolve
220
+ {/if}
221
+ </button>
222
+ {#if thread.root.user_id === currentUserId}
223
+ <button
224
+ class="btn btn-ghost btn-xs text-base-content/50"
225
+ onclick={() => startEdit(thread.root)}
226
+ >Edit</button>
227
+ <button
228
+ class="btn btn-ghost btn-xs text-error/70"
229
+ onclick={() => deleteAnnotation(thread.root.id)}
230
+ >Delete</button>
231
+ {/if}
232
+ </div>
233
+
234
+ <!-- Edit form for root -->
235
+ {#if editingId === thread.root.id}
236
+ <div class="ml-8 space-y-1.5">
237
+ <textarea
238
+ class="textarea textarea-bordered textarea-sm w-full"
239
+ rows="2"
240
+ bind:value={editContent}
241
+ ></textarea>
242
+ <div class="flex gap-1">
243
+ <button class="btn btn-xs btn-primary" onclick={() => saveEdit(thread.root.id)} disabled={isSubmitting}>Save</button>
244
+ <button class="btn btn-xs btn-ghost" onclick={() => { editingId = null; editContent = ""; }}>Cancel</button>
245
+ </div>
246
+ </div>
247
+ {/if}
248
+
249
+ <!-- Replies -->
250
+ {#each thread.replies as reply (reply.id)}
251
+ <div class="ml-8 flex items-start gap-2 bg-base-200/50 rounded-lg p-2">
252
+ <div class="flex-shrink-0 w-5 h-5 rounded-full bg-secondary/10 text-secondary flex items-center justify-center text-[9px] font-bold mt-0.5">
253
+ {getInitials(reply.user_id)}
254
+ </div>
255
+ <div class="flex-1 min-w-0">
256
+ <div class="flex items-center gap-1.5">
257
+ <span class="text-xs font-medium">{getAuthorName(reply.user_id)}</span>
258
+ <span class="text-[10px] text-base-content/40">{formatTime(reply.created_at)}</span>
259
+ </div>
260
+ {#if editingId === reply.id}
261
+ <div class="mt-1 space-y-1.5">
262
+ <textarea
263
+ class="textarea textarea-bordered textarea-xs w-full"
264
+ rows="2"
265
+ bind:value={editContent}
266
+ ></textarea>
267
+ <div class="flex gap-1">
268
+ <button class="btn btn-xs btn-primary" onclick={() => saveEdit(reply.id)} disabled={isSubmitting}>Save</button>
269
+ <button class="btn btn-xs btn-ghost" onclick={() => { editingId = null; editContent = ""; }}>Cancel</button>
270
+ </div>
271
+ </div>
272
+ {:else}
273
+ <p class="text-sm text-base-content/80 mt-0.5">{reply.content}</p>
274
+ {#if reply.user_id === currentUserId}
275
+ <div class="flex gap-1 mt-1">
276
+ <button class="btn btn-ghost btn-xs text-base-content/40 h-5 min-h-0" onclick={() => startEdit(reply)}>Edit</button>
277
+ <button class="btn btn-ghost btn-xs text-error/50 h-5 min-h-0" onclick={() => deleteAnnotation(reply.id)}>Delete</button>
278
+ </div>
279
+ {/if}
280
+ {/if}
281
+ </div>
282
+ </div>
283
+ {/each}
284
+
285
+ <!-- Reply input -->
286
+ <div class="ml-8 flex gap-2">
287
+ <textarea
288
+ class="textarea textarea-bordered textarea-sm flex-1 min-h-[36px]"
289
+ rows="1"
290
+ placeholder="Reply..."
291
+ bind:value={replyContent}
292
+ onkeydown={(e) => {
293
+ if (e.key === "Enter" && !e.shiftKey) {
294
+ e.preventDefault()
295
+ submitReply(thread.root.id)
296
+ }
297
+ }}
298
+ ></textarea>
299
+ <button
300
+ class="btn btn-sm btn-primary self-end"
301
+ disabled={!replyContent.trim() || isSubmitting}
302
+ onclick={() => submitReply(thread.root.id)}
303
+ >
304
+ {#if isSubmitting}
305
+ <span class="loading loading-spinner loading-xs"></span>
306
+ {:else}
307
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
308
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
309
+ </svg>
310
+ {/if}
311
+ </button>
312
+ </div>
313
+ </div>
314
+ {/if}
315
+ </div>
316
+ {/each}
317
+ {/if}
318
+ </div>
319
+ </div>
@@ -15,9 +15,9 @@
15
15
  waveColor = "neutral",
16
16
  progressColor = "accent",
17
17
  backgroundColor = "base-100",
18
- waveColorHex = "#6b7280",
19
- progressColorHex = "#f97316",
20
- backgroundColorHex = "#f1f5f9",
18
+ waveColorHex = "",
19
+ progressColorHex = "",
20
+ backgroundColorHex = "",
21
21
  interactive = true,
22
22
  showTrimControls = false,
23
23
  onTrim,
@@ -69,8 +69,11 @@
69
69
  }
70
70
  }
71
71
 
72
+ // svelte-ignore state_referenced_locally
72
73
  let resolvedWaveColor = $state(resolveColor(waveColor, waveColorHex))
74
+ // svelte-ignore state_referenced_locally
73
75
  let resolvedProgressColor = $state(resolveColor(progressColor, progressColorHex))
76
+ // svelte-ignore state_referenced_locally
74
77
  let resolvedBackgroundColor = $state(resolveColor(backgroundColor, backgroundColorHex))
75
78
 
76
79
  function updateResolvedColors() {
@@ -79,7 +82,7 @@
79
82
  resolvedBackgroundColor = resolveColor(backgroundColor, backgroundColorHex)
80
83
  }
81
84
 
82
- onMount(async () => {
85
+ onMount(() => {
83
86
  const cleanupThemeWatcher = watchThemeChanges(() => {
84
87
  updateResolvedColors()
85
88
  if (canvasContext && canvas && waveformData.length > 0) {
@@ -488,6 +491,7 @@
488
491
  audio.play()
489
492
 
490
493
  const checkTime = () => {
494
+ if (!audio) return
491
495
  if (audio.currentTime >= trimEnd) {
492
496
  audio.pause()
493
497
  } else if (isPlaying) {
@@ -612,7 +616,7 @@
612
616
 
613
617
  {#if isHovering && interactive && hoverX > 0}
614
618
  <div
615
- class="absolute pointer-events-none bg-base-900 text-base-100 text-xs px-2 py-1 rounded shadow-lg z-10"
619
+ class="absolute pointer-events-none bg-neutral text-neutral-content text-xs px-2 py-1 rounded z-10"
616
620
  style="left: {hoverX - 20}px; top: -30px;"
617
621
  >
618
622
  {formatTime(hoverTime)}
@@ -22,9 +22,13 @@
22
22
  onClose: () => void;
23
23
  } = $props();
24
24
 
25
+ // svelte-ignore state_referenced_locally
25
26
  const initialType = existingOverride?.override_type || 'unavailable';
27
+ // svelte-ignore state_referenced_locally
26
28
  const initialStart = existingOverride?.start_time?.slice(0, 5) || '09:00';
29
+ // svelte-ignore state_referenced_locally
27
30
  const initialEnd = existingOverride?.end_time?.slice(0, 5) || '17:00';
31
+ // svelte-ignore state_referenced_locally
28
32
  const initialReason = existingOverride?.reason || '';
29
33
 
30
34
  let overrideType = $state<OverrideType>(initialType);
@@ -66,15 +70,15 @@
66
70
  <!-- svelte-ignore a11y_click_events_have_key_events -->
67
71
  <!-- svelte-ignore a11y_no_static_element_interactions -->
68
72
  <div
69
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
73
+ class="fixed inset-0 z-50 flex items-center justify-center bg-[color-mix(in_oklch,#180042_55%,transparent)] p-4"
70
74
  onclick={handleBackdropClick}
71
75
  transition:slide={{ duration: 200 }}
72
76
  >
73
- <div class="bg-base-100 rounded-2xl border border-base-300 shadow-2xl w-full max-w-md" transition:slide={{ duration: 300 }}>
77
+ <div class="bg-base-100 rounded-md border border-base-300 w-full max-w-md" transition:slide={{ duration: 300 }}>
74
78
  <!-- Header -->
75
79
  <div class="p-6 border-b border-base-300">
76
80
  <div class="flex items-center justify-between">
77
- <h2 class="text-xl font-bold">Set Date Override</h2>
81
+ <h2 class="text-lg font-semibold">Set Date Override</h2>
78
82
  <button onclick={onClose} class="btn btn-ghost btn-sm btn-circle" aria-label="Close">
79
83
  <!-- X icon -->
80
84
  <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -41,9 +41,31 @@
41
41
  let error = $state("")
42
42
  let previewUrl = $state<string | null>(null)
43
43
  let fileInput: HTMLInputElement | undefined = $state()
44
+ let justUploaded = $state(false)
44
45
 
45
46
  const displayUrl = $derived(previewUrl ?? avatarUrl)
46
47
 
48
+ // Flash green border briefly when uploading transitions to done
49
+ $effect(() => {
50
+ if (!uploading && previewUrl) {
51
+ justUploaded = true
52
+ const t = setTimeout(() => { justUploaded = false }, 1000)
53
+ return () => clearTimeout(t)
54
+ }
55
+ })
56
+
57
+ // Colorized initials background — consistent color per user
58
+ const avatarBg = $derived.by(() => {
59
+ const source = name || email || ""
60
+ if (!source) return "oklch(0.62 0.14 240)"
61
+ let hash = 0
62
+ for (let i = 0; i < source.length; i++) {
63
+ hash = source.charCodeAt(i) + ((hash << 5) - hash)
64
+ }
65
+ const hues = [30, 85, 145, 200, 240, 270, 310, 350]
66
+ return `oklch(0.62 0.14 ${hues[Math.abs(hash) % hues.length]})`
67
+ })
68
+
47
69
  const initials = $derived.by(() => {
48
70
  const source = name || email || ""
49
71
  if (!source) return "?"
@@ -144,7 +166,8 @@
144
166
  <!-- Avatar circle -->
145
167
  <button
146
168
  type="button"
147
- class="relative group rounded-full overflow-hidden w-20 h-20 shrink-0 cursor-pointer border-2 border-base-300 hover:border-primary transition-colors"
169
+ class="relative group rounded-full overflow-hidden w-20 h-20 shrink-0 cursor-pointer border-2 transition-all duration-300
170
+ {justUploaded ? 'border-success scale-[1.04]' : 'border-base-300 hover:border-primary'}"
148
171
  class:opacity-50={disabled || uploading}
149
172
  onclick={openPicker}
150
173
  {disabled}
@@ -152,14 +175,14 @@
152
175
  {#if displayUrl}
153
176
  <img src={displayUrl} alt="Avatar" class="w-full h-full object-cover" />
154
177
  {:else}
155
- <div class="w-full h-full bg-neutral text-neutral-content flex items-center justify-center text-2xl font-semibold">
178
+ <div class="w-full h-full flex items-center justify-center text-2xl font-semibold text-white" style="background: {avatarBg}">
156
179
  {initials}
157
180
  </div>
158
181
  {/if}
159
182
 
160
183
  <!-- Hover overlay -->
161
184
  {#if !disabled && !uploading}
162
- <div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
185
+ <div class="absolute inset-0 bg-[color-mix(in_oklch,#180042_45%,transparent)] opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
163
186
  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
164
187
  <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
165
188
  <circle cx="12" cy="13" r="4"/>
@@ -169,7 +192,7 @@
169
192
 
170
193
  <!-- Loading spinner -->
171
194
  {#if uploading}
172
- <div class="absolute inset-0 bg-black/40 flex items-center justify-center">
195
+ <div class="absolute inset-0 bg-[color-mix(in_oklch,#180042_45%,transparent)] flex items-center justify-center">
173
196
  <span class="loading loading-spinner loading-sm text-white"></span>
174
197
  </div>
175
198
  {/if}
@@ -48,7 +48,9 @@
48
48
  accentColor?: string;
49
49
  } = $props();
50
50
 
51
+ // svelte-ignore state_referenced_locally
51
52
  let guestName = $state(initialName);
53
+ // svelte-ignore state_referenced_locally
52
54
  let guestEmail = $state(initialEmail);
53
55
  let guestPhone = $state('');
54
56
  let guestNotes = $state('');
@@ -101,7 +103,7 @@
101
103
  <form onsubmit={handleSubmit} class="space-y-6">
102
104
  <!-- Name -->
103
105
  <div>
104
- <label for="booking-name" class="block text-sm font-medium text-base-content/70 mb-2">
106
+ <label for="booking-name" class="block text-[0.8125rem] font-medium text-base-content/85 mb-2">
105
107
  Your Name <span class="text-error">*</span>
106
108
  </label>
107
109
  <input
@@ -117,7 +119,7 @@
117
119
 
118
120
  <!-- Email -->
119
121
  <div>
120
- <label for="booking-email" class="block text-sm font-medium text-base-content/70 mb-2">
122
+ <label for="booking-email" class="block text-[0.8125rem] font-medium text-base-content/85 mb-2">
121
123
  Email Address <span class="text-error">*</span>
122
124
  </label>
123
125
  <input
@@ -129,14 +131,14 @@
129
131
  disabled={submitting}
130
132
  class="input input-bordered w-full bg-base-200/50 focus:border-{accentColor}"
131
133
  />
132
- <p class="text-xs text-base-content/50 mt-1">
134
+ <p class="text-[0.75rem] text-base-content/45 mt-1">
133
135
  Confirmation and meeting details will be sent here
134
136
  </p>
135
137
  </div>
136
138
 
137
139
  <!-- Phone -->
138
140
  <div>
139
- <label for="booking-phone" class="block text-sm font-medium text-base-content/70 mb-2">
141
+ <label for="booking-phone" class="block text-[0.8125rem] font-medium text-base-content/85 mb-2">
140
142
  Phone Number {#if requirePhone}<span class="text-error">*</span>{:else}<span class="text-base-content/50">(optional)</span>{/if}
141
143
  </label>
142
144
  <input
@@ -149,7 +151,7 @@
149
151
  class="input input-bordered w-full bg-base-200/50 focus:border-{accentColor}"
150
152
  />
151
153
  {#if locationHint}
152
- <p class="text-xs text-base-content/50 mt-1">{locationHint}</p>
154
+ <p class="text-[0.75rem] text-base-content/45 mt-1">{locationHint}</p>
153
155
  {/if}
154
156
  </div>
155
157
 
@@ -158,8 +160,8 @@
158
160
  <div class="border-t border-base-300 pt-6">
159
161
  <div class="flex items-center justify-between mb-4">
160
162
  <div>
161
- <h3 class="text-sm font-medium">Additional Attendees</h3>
162
- <p class="text-xs text-base-content/50">Invite team members to join the meeting</p>
163
+ <h3 class="text-[0.9375rem] font-medium">Additional Attendees</h3>
164
+ <p class="text-[0.75rem] text-base-content/45">Invite team members to join the meeting</p>
163
165
  </div>
164
166
  {#if !showAttendees}
165
167
  <button
@@ -231,7 +233,7 @@
231
233
  <!-- Notes -->
232
234
  {#if showNotes}
233
235
  <div>
234
- <label for="booking-notes" class="block text-sm font-medium text-base-content/70 mb-2">
236
+ <label for="booking-notes" class="block text-[0.8125rem] font-medium text-base-content/85 mb-2">
235
237
  Additional Notes <span class="text-base-content/50">(optional)</span>
236
238
  </label>
237
239
  <textarea
@@ -247,7 +249,7 @@
247
249
 
248
250
  <!-- Terms Notice -->
249
251
  {#if showTerms}
250
- <div class="bg-base-200/30 rounded-lg p-4 text-sm text-base-content/60">
252
+ <div class="bg-base-200/30 rounded-lg p-4 text-[0.8125rem] text-base-content/45">
251
253
  <p>
252
254
  By scheduling this meeting, you agree to our
253
255
  <a href={termsUrl} class="text-{accentColor} hover:underline">Terms of Service</a>