@joewinke/jatui 0.1.11 → 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.
- package/README.md +123 -0
- package/package.json +2 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +21 -15
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +59 -19
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +15 -9
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +1 -1
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/index.ts +91 -0
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- 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, '&')
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/'/g, ''')
|
|
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}
|