@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,773 @@
1
+ <!--
2
+ CommentThread — universal threaded comments for any commentable entity
3
+ (project, invoice, contract, asset, task, form submission).
4
+
5
+ Transport-agnostic: all data operations go through the `callbacks` prop,
6
+ so the component imports no server code and has no DB/framework coupling
7
+ (same house style as MessageThread / UserAvatar). The consuming page
8
+ wires the callbacks to a SvelteKit load + form action or a fetch API.
9
+
10
+ Threading is one level deep on the data side (a reply's parent is always
11
+ a top-level comment); rendered as two visual levels. Replies to a reply
12
+ are re-anchored to the top-level parent and inherit its visibility —
13
+ this mirrors the server contract so optimistic rendering stays correct.
14
+
15
+ staffView=true → staff: internal + client comments, visibility toggle.
16
+ staffView=false → portal: client-only, every post forced to 'client'.
17
+ -->
18
+
19
+ <script lang="ts">
20
+ import { slide, fade } from 'svelte/transition'
21
+ import { untrack } from 'svelte'
22
+ import UserAvatar from './UserAvatar.svelte'
23
+ import { formatRelativeTime, formatFullDate } from '../utils/dateFormatters'
24
+ import type {
25
+ Comment,
26
+ CommentCallbacks,
27
+ CommentEntityType,
28
+ CommentVisibility
29
+ } from '../types/comments'
30
+
31
+ interface Props {
32
+ entityType: CommentEntityType
33
+ entityId: string
34
+ /** Used for own-comment ownership checks (edit/delete). */
35
+ currentUserId: string
36
+ /** false = portal mode (client-only, no internal toggle). Default true. */
37
+ staffView?: boolean
38
+ /**
39
+ * When false, the staff internal/client visibility toggle is hidden,
40
+ * every new comment is forced to 'internal', and the per-comment
41
+ * visibility badge is suppressed. Use for entities that are never
42
+ * portal-exposed (e.g. internal tasks). Default true (no change to
43
+ * existing staff threads). Ignored when staffView is false.
44
+ */
45
+ allowClientVisibility?: boolean
46
+ /** Allow edit/delete of other people's comments (team owner/admin). */
47
+ isAdmin?: boolean
48
+ /** Initial top-level comments (typically from a server load). */
49
+ comments?: Comment[]
50
+ /** Total top-level comments matching the visibility filter (for paging). */
51
+ total?: number
52
+ /** Show the skeleton loading state instead of the thread. */
53
+ loading?: boolean
54
+ callbacks: CommentCallbacks
55
+ placeholder?: string
56
+ emptyText?: string
57
+ class?: string
58
+ }
59
+
60
+ let {
61
+ entityType,
62
+ entityId,
63
+ currentUserId,
64
+ staffView = true,
65
+ allowClientVisibility = true,
66
+ isAdmin = false,
67
+ comments = [],
68
+ total = 0,
69
+ loading = false,
70
+ callbacks,
71
+ placeholder = 'Write a comment…',
72
+ emptyText = 'No comments yet.',
73
+ class: className = ''
74
+ }: Props = $props()
75
+
76
+ // ── Local working copy ────────────────────────────────────────────────
77
+ // Re-synced from the `comments` prop only when its reference changes
78
+ // (server navigation / fresh load), so optimistic mutations survive
79
+ // re-renders. The identity guard prevents an effect write loop.
80
+ let items = $state<Comment[]>([])
81
+ let syncedFrom: Comment[] | null = null
82
+ $effect(() => {
83
+ if (comments !== syncedFrom) {
84
+ syncedFrom = comments
85
+ items = comments.map((c) => ({ ...c, replies: [...(c.replies ?? [])] }))
86
+ }
87
+ })
88
+
89
+ // In portal mode, defensively show only client-visible rows even if the
90
+ // server over-returns (it shouldn't — visibility is enforced server-side).
91
+ const visibleItems = $derived(
92
+ staffView ? items : items.filter((c) => c.visibility === 'client')
93
+ )
94
+
95
+ // Show the internal/client toggle + per-comment badge only for staff
96
+ // AND when the entity can have client-visible comments at all.
97
+ const showVisibilityControls = $derived(staffView && allowClientVisibility)
98
+
99
+ // ── New-comment composer ──────────────────────────────────────────────
100
+ let newBody = $state('')
101
+ // untrack: staffView is fixed per mount; capture it once without reactive tracking.
102
+ let newVisibility = $state<CommentVisibility>(untrack(() => (staffView ? 'internal' : 'client')))
103
+ let posting = $state(false)
104
+ let postError = $state<string | null>(null)
105
+
106
+ // ── Reply composer (one open at a time, keyed by top-level id) ─────────
107
+ let replyingTo = $state<string | null>(null)
108
+ let replyBody = $state('')
109
+ let replyBusy = $state(false)
110
+ let replyError = $state<string | null>(null)
111
+
112
+ // ── Inline edit ───────────────────────────────────────────────────────
113
+ let editingId = $state<string | null>(null)
114
+ let editBody = $state('')
115
+ let editBusy = $state(false)
116
+ let editError = $state<string | null>(null)
117
+
118
+ // ── Misc per-row state ────────────────────────────────────────────────
119
+ let confirmDeleteId = $state<string | null>(null)
120
+ let deletingId = $state<string | null>(null)
121
+ let expanded = $state<Set<string>>(new Set())
122
+ let repliesLoading = $state<Set<string>>(new Set())
123
+ let loadingMore = $state(false)
124
+
125
+ function canModify(c: Comment): boolean {
126
+ return !c.deleted_at && (c.author_id === currentUserId || isAdmin)
127
+ }
128
+
129
+ function isDeleted(c: Comment): boolean {
130
+ return !!c.deleted_at || c.body === '[deleted]'
131
+ }
132
+
133
+ // ── Body rendering: escape → @mention highlight → newlines ────────────
134
+ // Mention rule mirrors the server's parseMentions regex exactly, so what
135
+ // the UI highlights == what the server notifies. Email addresses
136
+ // (`foo@bar.com`) are not mentions because `@` follows a word char.
137
+ function escapeHtml(s: string): string {
138
+ return s
139
+ .replace(/&/g, '&amp;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;')
142
+ .replace(/"/g, '&quot;')
143
+ .replace(/'/g, '&#039;')
144
+ }
145
+
146
+ function renderBody(body: string): string {
147
+ const re = /(^|[^\w@])@([a-zA-Z0-9_.-]+)/g
148
+ let out = ''
149
+ let last = 0
150
+ let m: RegExpExecArray | null
151
+ while ((m = re.exec(body)) !== null) {
152
+ const handle = m[2].replace(/[.\-_]+$/, '')
153
+ if (!handle) continue
154
+ out += escapeHtml(body.slice(last, m.index))
155
+ out += escapeHtml(m[1])
156
+ out += `<span class="text-primary font-medium">@${escapeHtml(handle)}</span>`
157
+ out += escapeHtml(m[2].slice(handle.length))
158
+ last = m.index + m[0].length
159
+ }
160
+ out += escapeHtml(body.slice(last))
161
+ return out.replace(/\n/g, '<br>')
162
+ }
163
+
164
+ function findTop(id: string): Comment | undefined {
165
+ return items.find((c) => c.id === id)
166
+ }
167
+
168
+ // ── Post a new top-level comment ──────────────────────────────────────
169
+ async function submitNew() {
170
+ const body = newBody.trim()
171
+ if (!body || posting) return
172
+ posting = true
173
+ postError = null
174
+ try {
175
+ const created = await callbacks.submitComment({
176
+ entityType,
177
+ entityId,
178
+ body,
179
+ parentId: null,
180
+ visibility: staffView ? (allowClientVisibility ? newVisibility : 'internal') : 'client'
181
+ })
182
+ created.replies = created.replies ?? []
183
+ items = [created, ...items]
184
+ newBody = ''
185
+ } catch (e) {
186
+ postError = e instanceof Error ? e.message : 'Failed to post comment.'
187
+ } finally {
188
+ posting = false
189
+ }
190
+ }
191
+
192
+ // ── Reply (always anchored to the TOP-LEVEL comment) ──────────────────
193
+ function openReply(topId: string) {
194
+ replyingTo = replyingTo === topId ? null : topId
195
+ replyBody = ''
196
+ replyError = null
197
+ }
198
+
199
+ async function submitReply(topId: string) {
200
+ const body = replyBody.trim()
201
+ if (!body || replyBusy) return
202
+ const parent = findTop(topId)
203
+ if (!parent) return
204
+ replyBusy = true
205
+ replyError = null
206
+ try {
207
+ const created = await callbacks.submitComment({
208
+ entityType,
209
+ entityId,
210
+ body,
211
+ parentId: topId,
212
+ // Replies inherit the parent's visibility (server clamps too).
213
+ visibility: parent.visibility
214
+ })
215
+ created.replies = created.replies ?? []
216
+ parent.replies = [...parent.replies, created]
217
+ parent.reply_count = (parent.reply_count ?? 0) + 1
218
+ expanded = new Set(expanded).add(topId)
219
+ replyBody = ''
220
+ replyingTo = null
221
+ } catch (e) {
222
+ replyError = e instanceof Error ? e.message : 'Failed to post reply.'
223
+ } finally {
224
+ replyBusy = false
225
+ }
226
+ }
227
+
228
+ // ── Expand / collapse a thread's replies ──────────────────────────────
229
+ async function toggleReplies(c: Comment) {
230
+ if (expanded.has(c.id)) {
231
+ const next = new Set(expanded)
232
+ next.delete(c.id)
233
+ expanded = next
234
+ return
235
+ }
236
+ expanded = new Set(expanded).add(c.id)
237
+ if (c.replies.length === 0 && c.reply_count > 0) {
238
+ repliesLoading = new Set(repliesLoading).add(c.id)
239
+ try {
240
+ c.replies = await callbacks.loadReplies(c.id)
241
+ } catch {
242
+ /* leave thread collapsed-empty; user can retry by toggling */
243
+ } finally {
244
+ const next = new Set(repliesLoading)
245
+ next.delete(c.id)
246
+ repliesLoading = next
247
+ }
248
+ }
249
+ }
250
+
251
+ // ── Inline edit ───────────────────────────────────────────────────────
252
+ function startEdit(c: Comment) {
253
+ editingId = c.id
254
+ editBody = c.body
255
+ editError = null
256
+ }
257
+
258
+ function cancelEdit() {
259
+ editingId = null
260
+ editBody = ''
261
+ editError = null
262
+ }
263
+
264
+ function applyEdited(updated: Comment) {
265
+ const top = items.find((c) => c.id === updated.id)
266
+ if (top) {
267
+ top.body = updated.body
268
+ top.edited_at = updated.edited_at
269
+ return
270
+ }
271
+ for (const c of items) {
272
+ const r = c.replies.find((x) => x.id === updated.id)
273
+ if (r) {
274
+ r.body = updated.body
275
+ r.edited_at = updated.edited_at
276
+ return
277
+ }
278
+ }
279
+ }
280
+
281
+ async function saveEdit(c: Comment) {
282
+ const body = editBody.trim()
283
+ if (!body || editBusy) return
284
+ if (body === c.body) {
285
+ cancelEdit()
286
+ return
287
+ }
288
+ editBusy = true
289
+ editError = null
290
+ try {
291
+ const updated = await callbacks.editComment(c.id, body)
292
+ applyEdited(updated)
293
+ cancelEdit()
294
+ } catch (e) {
295
+ editError = e instanceof Error ? e.message : 'Failed to save edit.'
296
+ } finally {
297
+ editBusy = false
298
+ }
299
+ }
300
+
301
+ // ── Soft-delete (row kept so replies stay anchored) ───────────────────
302
+ function applyDeleted(id: string) {
303
+ const top = items.find((c) => c.id === id)
304
+ if (top) {
305
+ top.deleted_at = new Date().toISOString()
306
+ top.body = '[deleted]'
307
+ return
308
+ }
309
+ for (const c of items) {
310
+ const r = c.replies.find((x) => x.id === id)
311
+ if (r) {
312
+ r.deleted_at = new Date().toISOString()
313
+ r.body = '[deleted]'
314
+ return
315
+ }
316
+ }
317
+ }
318
+
319
+ async function confirmDelete(c: Comment) {
320
+ if (deletingId) return
321
+ deletingId = c.id
322
+ try {
323
+ await callbacks.deleteComment(c.id)
324
+ applyDeleted(c.id)
325
+ confirmDeleteId = null
326
+ } catch {
327
+ /* keep the confirm open so the user can retry */
328
+ } finally {
329
+ deletingId = null
330
+ }
331
+ }
332
+
333
+ async function loadMore() {
334
+ if (!callbacks.loadMore || loadingMore) return
335
+ loadingMore = true
336
+ try {
337
+ const res = await callbacks.loadMore(items.length)
338
+ const seen = new Set(items.map((c) => c.id))
339
+ const fresh = res.comments
340
+ .filter((c) => !seen.has(c.id))
341
+ .map((c) => ({ ...c, replies: [...(c.replies ?? [])] }))
342
+ items = [...items, ...fresh]
343
+ total = res.total
344
+ } catch {
345
+ /* swallow — button stays for retry */
346
+ } finally {
347
+ loadingMore = false
348
+ }
349
+ }
350
+
351
+ const hasMore = $derived(
352
+ !!callbacks.loadMore && staffView && total > items.length
353
+ )
354
+ </script>
355
+
356
+ <div class="flex flex-col gap-4 {className}">
357
+ {#if loading}
358
+ <!-- Skeleton loading state -->
359
+ <div class="flex flex-col gap-4" aria-busy="true" aria-label="Loading comments">
360
+ {#each [0, 1, 2] as i (i)}
361
+ <div class="flex gap-3">
362
+ <div class="skeleton h-9 w-9 shrink-0 rounded-full"></div>
363
+ <div class="flex-1 space-y-2">
364
+ <div class="skeleton h-3 w-32"></div>
365
+ <div class="skeleton h-3 w-full"></div>
366
+ <div class="skeleton h-3 w-2/3"></div>
367
+ </div>
368
+ </div>
369
+ {/each}
370
+ </div>
371
+ {:else}
372
+ <!-- Thread -->
373
+ {#if visibleItems.length === 0}
374
+ <div class="flex flex-col items-center gap-2 py-8 text-center">
375
+ <svg class="w-8 h-8 text-base-content/20" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
376
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
377
+ </svg>
378
+ <p class="text-[0.8125rem] text-base-content/40">{emptyText}</p>
379
+ </div>
380
+ {:else}
381
+ <ul class="flex flex-col gap-5">
382
+ {#each visibleItems as c (c.id)}
383
+ <li in:fade={{ duration: 150 }}>
384
+ {@render commentRow(c, false)}
385
+
386
+ <!-- Replies -->
387
+ {#if c.reply_count > 0 || c.replies.length > 0}
388
+ <div class="border-base-300 mt-2 ml-11 border-l pl-4">
389
+ <button
390
+ type="button"
391
+ class="link link-hover text-base-content/60 mb-2 text-xs"
392
+ onclick={() => toggleReplies(c)}
393
+ >
394
+ {#if repliesLoading.has(c.id)}
395
+ <span class="loading loading-spinner loading-xs align-middle"></span>
396
+ Loading replies…
397
+ {:else if expanded.has(c.id)}
398
+ Hide {c.reply_count}
399
+ {c.reply_count === 1 ? 'reply' : 'replies'}
400
+ {:else}
401
+ Show {c.reply_count}
402
+ {c.reply_count === 1 ? 'reply' : 'replies'}
403
+ {/if}
404
+ </button>
405
+
406
+ {#if expanded.has(c.id)}
407
+ <ul class="flex flex-col gap-4" transition:slide={{ duration: 150 }}>
408
+ {#each c.replies as r (r.id)}
409
+ <li in:fade={{ duration: 150 }}>
410
+ {@render commentRow(r, true)}
411
+ </li>
412
+ {/each}
413
+ </ul>
414
+ {/if}
415
+ </div>
416
+ {/if}
417
+
418
+ <!-- Inline reply composer -->
419
+ {#if replyingTo === c.id}
420
+ <div
421
+ class="mt-3 ml-11"
422
+ transition:slide={{ duration: 150 }}
423
+ >
424
+ <textarea
425
+ class="textarea textarea-bordered w-full text-sm"
426
+ rows="2"
427
+ aria-label="Write a reply"
428
+ placeholder="Write a reply…"
429
+ bind:value={replyBody}
430
+ disabled={replyBusy}
431
+ ></textarea>
432
+ {#if replyError}
433
+ <p class="text-error mt-1 text-xs">{replyError}</p>
434
+ {/if}
435
+ <div class="mt-2 flex items-center gap-2">
436
+ <button
437
+ type="button"
438
+ class="btn btn-primary btn-xs"
439
+ disabled={replyBusy || !replyBody.trim()}
440
+ onclick={() => submitReply(c.id)}
441
+ >
442
+ {#if replyBusy}
443
+ <span class="loading loading-spinner loading-xs"></span>
444
+ {/if}
445
+ Reply
446
+ </button>
447
+ <button
448
+ type="button"
449
+ class="btn btn-ghost btn-xs"
450
+ disabled={replyBusy}
451
+ onclick={() => (replyingTo = null)}
452
+ >
453
+ Cancel
454
+ </button>
455
+ </div>
456
+ </div>
457
+ {/if}
458
+ </li>
459
+ {/each}
460
+ </ul>
461
+
462
+ {#if hasMore}
463
+ <button
464
+ type="button"
465
+ class="btn btn-ghost btn-sm self-center"
466
+ disabled={loadingMore}
467
+ onclick={loadMore}
468
+ >
469
+ {#if loadingMore}
470
+ <span class="loading loading-spinner loading-xs"></span>
471
+ {/if}
472
+ Load older comments
473
+ </button>
474
+ {/if}
475
+ {/if}
476
+
477
+ <!-- New comment composer -->
478
+ <div class="border-base-300 mt-2 border-t pt-4">
479
+ <textarea
480
+ class="textarea textarea-bordered w-full"
481
+ rows="3"
482
+ aria-label="Write a comment"
483
+ {placeholder}
484
+ bind:value={newBody}
485
+ disabled={posting}
486
+ ></textarea>
487
+ {#if postError}
488
+ <p class="text-error mt-1 text-xs">{postError}</p>
489
+ {/if}
490
+ <div class="mt-2 flex flex-wrap items-center justify-between gap-2">
491
+ {#if showVisibilityControls}
492
+ <div class="join" role="group" aria-label="Comment visibility">
493
+ <button
494
+ type="button"
495
+ class="btn btn-xs join-item {newVisibility === 'internal'
496
+ ? 'btn-active btn-neutral'
497
+ : 'btn-ghost'}"
498
+ aria-pressed={newVisibility === 'internal'}
499
+ onclick={() => (newVisibility = 'internal')}
500
+ >
501
+ {@render lockIcon()}
502
+ Internal
503
+ </button>
504
+ <button
505
+ type="button"
506
+ class="btn btn-xs join-item {newVisibility === 'client'
507
+ ? 'btn-active btn-primary'
508
+ : 'btn-ghost'}"
509
+ aria-pressed={newVisibility === 'client'}
510
+ onclick={() => (newVisibility = 'client')}
511
+ >
512
+ {@render eyeIcon()}
513
+ Client-visible
514
+ </button>
515
+ </div>
516
+ {:else}
517
+ <span class="text-base-content/50 inline-flex items-center gap-1 text-xs">
518
+ {@render eyeIcon()}
519
+ Visible to your team
520
+ </span>
521
+ {/if}
522
+ <button
523
+ type="button"
524
+ class="btn btn-primary btn-sm"
525
+ disabled={posting || !newBody.trim()}
526
+ onclick={submitNew}
527
+ >
528
+ {#if posting}
529
+ <span class="loading loading-spinner loading-xs"></span>
530
+ {/if}
531
+ Comment
532
+ </button>
533
+ </div>
534
+ </div>
535
+ {/if}
536
+ </div>
537
+
538
+ <!-- ── Snippets ──────────────────────────────────────────────────────── -->
539
+
540
+ {#snippet lockIcon()}
541
+ <svg
542
+ xmlns="http://www.w3.org/2000/svg"
543
+ viewBox="0 0 24 24"
544
+ fill="none"
545
+ stroke="currentColor"
546
+ stroke-width="2"
547
+ stroke-linecap="round"
548
+ stroke-linejoin="round"
549
+ class="h-3.5 w-3.5"
550
+ aria-hidden="true"
551
+ >
552
+ <rect x="3" y="11" width="18" height="11" rx="2" />
553
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
554
+ </svg>
555
+ {/snippet}
556
+
557
+ {#snippet eyeIcon()}
558
+ <svg
559
+ xmlns="http://www.w3.org/2000/svg"
560
+ viewBox="0 0 24 24"
561
+ fill="none"
562
+ stroke="currentColor"
563
+ stroke-width="2"
564
+ stroke-linecap="round"
565
+ stroke-linejoin="round"
566
+ class="h-3.5 w-3.5"
567
+ aria-hidden="true"
568
+ >
569
+ <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
570
+ <circle cx="12" cy="12" r="3" />
571
+ </svg>
572
+ {/snippet}
573
+
574
+ {#snippet replyIcon()}
575
+ <svg
576
+ xmlns="http://www.w3.org/2000/svg"
577
+ viewBox="0 0 24 24"
578
+ fill="none"
579
+ stroke="currentColor"
580
+ stroke-width="2"
581
+ stroke-linecap="round"
582
+ stroke-linejoin="round"
583
+ class="h-3.5 w-3.5"
584
+ aria-hidden="true"
585
+ >
586
+ <polyline points="9 17 4 12 9 7" />
587
+ <path d="M20 18v-2a4 4 0 0 0-4-4H4" />
588
+ </svg>
589
+ {/snippet}
590
+
591
+ {#snippet editIcon()}
592
+ <svg
593
+ xmlns="http://www.w3.org/2000/svg"
594
+ viewBox="0 0 24 24"
595
+ fill="none"
596
+ stroke="currentColor"
597
+ stroke-width="2"
598
+ stroke-linecap="round"
599
+ stroke-linejoin="round"
600
+ class="h-3.5 w-3.5"
601
+ aria-hidden="true"
602
+ >
603
+ <path d="M12 20h9" />
604
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
605
+ </svg>
606
+ {/snippet}
607
+
608
+ {#snippet trashIcon()}
609
+ <svg
610
+ xmlns="http://www.w3.org/2000/svg"
611
+ viewBox="0 0 24 24"
612
+ fill="none"
613
+ stroke="currentColor"
614
+ stroke-width="2"
615
+ stroke-linecap="round"
616
+ stroke-linejoin="round"
617
+ class="h-3.5 w-3.5"
618
+ aria-hidden="true"
619
+ >
620
+ <polyline points="3 6 5 6 21 6" />
621
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
622
+ </svg>
623
+ {/snippet}
624
+
625
+ {#snippet commentRow(c: Comment, isReply: boolean)}
626
+ <div class="flex gap-3">
627
+ <UserAvatar
628
+ name={c.author_name}
629
+ avatarUrl={c.author_avatar_url}
630
+ size={isReply ? 'xs' : 'sm'}
631
+ class="mt-0.5"
632
+ />
633
+ <div class="min-w-0 flex-1">
634
+ <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
635
+ <span class="font-semibold">{c.author_name ?? 'Unknown'}</span>
636
+ <span
637
+ class="text-base-content/50 text-xs"
638
+ title={formatFullDate(c.created_at)}
639
+ >
640
+ {formatRelativeTime(c.created_at)}
641
+ </span>
642
+ {#if c.edited_at}
643
+ <span
644
+ class="text-base-content/40 text-xs"
645
+ title={formatFullDate(c.edited_at)}
646
+ >
647
+ · edited
648
+ </span>
649
+ {/if}
650
+ {#if showVisibilityControls}
651
+ {#if c.visibility === 'internal'}
652
+ <span
653
+ class="badge badge-ghost badge-xs gap-1"
654
+ title="Internal — staff only"
655
+ >
656
+ {@render lockIcon()}
657
+ Internal
658
+ </span>
659
+ {:else}
660
+ <span
661
+ class="badge badge-primary badge-xs gap-1"
662
+ title="Client-visible — shown in the portal"
663
+ >
664
+ {@render eyeIcon()}
665
+ Client
666
+ </span>
667
+ {/if}
668
+ {/if}
669
+ </div>
670
+
671
+ {#if editingId === c.id}
672
+ <div class="mt-1.5">
673
+ <textarea
674
+ class="textarea textarea-bordered w-full text-sm"
675
+ rows="2"
676
+ aria-label="Edit comment"
677
+ bind:value={editBody}
678
+ disabled={editBusy}
679
+ ></textarea>
680
+ {#if editError}
681
+ <p class="text-error mt-1 text-xs">{editError}</p>
682
+ {/if}
683
+ <div class="mt-2 flex items-center gap-2">
684
+ <button
685
+ type="button"
686
+ class="btn btn-primary btn-xs"
687
+ disabled={editBusy || !editBody.trim()}
688
+ onclick={() => saveEdit(c)}
689
+ >
690
+ {#if editBusy}
691
+ <span class="loading loading-spinner loading-xs"></span>
692
+ {/if}
693
+ Save
694
+ </button>
695
+ <button
696
+ type="button"
697
+ class="btn btn-ghost btn-xs"
698
+ disabled={editBusy}
699
+ onclick={cancelEdit}
700
+ >
701
+ Cancel
702
+ </button>
703
+ </div>
704
+ </div>
705
+ {:else if isDeleted(c)}
706
+ <p class="text-base-content/40 mt-0.5 text-sm italic">[deleted]</p>
707
+ {:else}
708
+ <!-- Body is escaped in renderBody() before interpolation. -->
709
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
710
+ <div class="mt-0.5 text-sm break-words whitespace-pre-wrap">
711
+ {@html renderBody(c.body)}
712
+ </div>
713
+ {/if}
714
+
715
+ <!-- Action row -->
716
+ {#if editingId !== c.id && !isDeleted(c)}
717
+ <div class="mt-1.5 flex items-center gap-3">
718
+ <button
719
+ type="button"
720
+ class="text-base-content/50 hover:text-base-content inline-flex items-center gap-1 text-xs"
721
+ onclick={() => openReply(isReply ? (c.parent_id ?? c.id) : c.id)}
722
+ >
723
+ {@render replyIcon()}
724
+ Reply
725
+ </button>
726
+ {#if canModify(c)}
727
+ <button
728
+ type="button"
729
+ class="text-base-content/50 hover:text-base-content inline-flex items-center gap-1 text-xs"
730
+ onclick={() => startEdit(c)}
731
+ >
732
+ {@render editIcon()}
733
+ Edit
734
+ </button>
735
+ {#if confirmDeleteId === c.id}
736
+ <span class="inline-flex items-center gap-2 text-xs">
737
+ <span class="text-base-content/60">Delete?</span>
738
+ <button
739
+ type="button"
740
+ class="text-error hover:underline"
741
+ disabled={deletingId === c.id}
742
+ onclick={() => confirmDelete(c)}
743
+ >
744
+ {#if deletingId === c.id}
745
+ <span class="loading loading-spinner loading-xs"></span>
746
+ {/if}
747
+ Yes
748
+ </button>
749
+ <button
750
+ type="button"
751
+ class="text-base-content/60 hover:underline"
752
+ disabled={deletingId === c.id}
753
+ onclick={() => (confirmDeleteId = null)}
754
+ >
755
+ No
756
+ </button>
757
+ </span>
758
+ {:else}
759
+ <button
760
+ type="button"
761
+ class="text-base-content/50 hover:text-error inline-flex items-center gap-1 text-xs"
762
+ onclick={() => (confirmDeleteId = c.id)}
763
+ >
764
+ {@render trashIcon()}
765
+ Delete
766
+ </button>
767
+ {/if}
768
+ {/if}
769
+ </div>
770
+ {/if}
771
+ </div>
772
+ </div>
773
+ {/snippet}