@actuate-media/cms-admin 0.12.0 → 0.12.1

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 (152) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +200 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +45 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +90 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/coauthor-client.d.ts +72 -0
  99. package/dist/lib/coauthor-client.d.ts.map +1 -0
  100. package/dist/lib/coauthor-client.js +98 -0
  101. package/dist/lib/coauthor-client.js.map +1 -0
  102. package/dist/lib/collaboration-provider.d.ts +142 -0
  103. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  104. package/dist/lib/collaboration-provider.js +168 -0
  105. package/dist/lib/collaboration-provider.js.map +1 -0
  106. package/dist/lib/comment-anchor.d.ts +86 -0
  107. package/dist/lib/comment-anchor.d.ts.map +1 -0
  108. package/dist/lib/comment-anchor.js +139 -0
  109. package/dist/lib/comment-anchor.js.map +1 -0
  110. package/dist/lib/comments-client.d.ts +106 -0
  111. package/dist/lib/comments-client.d.ts.map +1 -0
  112. package/dist/lib/comments-client.js +138 -0
  113. package/dist/lib/comments-client.js.map +1 -0
  114. package/dist/lib/notifications-client.d.ts +68 -0
  115. package/dist/lib/notifications-client.d.ts.map +1 -0
  116. package/dist/lib/notifications-client.js +85 -0
  117. package/dist/lib/notifications-client.js.map +1 -0
  118. package/dist/lib/slash-command.d.ts +39 -0
  119. package/dist/lib/slash-command.d.ts.map +1 -0
  120. package/dist/lib/slash-command.js +50 -0
  121. package/dist/lib/slash-command.js.map +1 -0
  122. package/package.json +12 -3
  123. package/src/__tests__/components/comment-mark.test.ts +99 -0
  124. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  125. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  126. package/src/__tests__/components/offline-status.test.tsx +66 -0
  127. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  128. package/src/__tests__/lib/active-editor.test.ts +70 -0
  129. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  130. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  131. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  132. package/src/__tests__/lib/comments-client.test.ts +239 -0
  133. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  134. package/src/__tests__/lib/slash-command.test.ts +146 -0
  135. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  136. package/src/components/CoauthorResultPopover.tsx +193 -0
  137. package/src/components/CommandPalette.tsx +397 -209
  138. package/src/components/CommentMark.ts +138 -0
  139. package/src/components/CommentSidePanel.tsx +521 -0
  140. package/src/components/LivePreview.tsx +117 -11
  141. package/src/components/NotificationBell.tsx +242 -0
  142. package/src/components/OfflineStatus.tsx +90 -0
  143. package/src/components/PresenceChips.tsx +150 -0
  144. package/src/components/TipTapEditor.tsx +310 -12
  145. package/src/index.ts +20 -0
  146. package/src/lib/active-editor.ts +39 -0
  147. package/src/lib/coauthor-client.ts +160 -0
  148. package/src/lib/collaboration-provider.ts +306 -0
  149. package/src/lib/comment-anchor.ts +179 -0
  150. package/src/lib/comments-client.ts +208 -0
  151. package/src/lib/notifications-client.ts +146 -0
  152. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * TipTap inline mark that tags a text range with a `commentId`. The mark
3
+ * is the glue between the comment side panel and the editor — selecting
4
+ * a range, creating a comment, and then re-painting the highlight when
5
+ * the comment list updates all flow through this extension.
6
+ *
7
+ * Storage shape: a single attribute, `commentId`, that mirrors the row
8
+ * id returned by the slice-4 REST API. The mark also stamps a
9
+ * `data-comment-id` attribute on its rendered span and a CSS class so
10
+ * the panel can style it (yellow band for active, dotted underline for
11
+ * resolved, faded for detached anchors).
12
+ *
13
+ * We deliberately use a TipTap Mark (vs. a ProseMirror decoration) so
14
+ * the comment anchor is *persisted* in the document HTML and round-trips
15
+ * through the autosave path. Decorations would be simpler but they're
16
+ * presentational only — they disappear on next render unless re-painted
17
+ * from scratch, which would defeat slice 5's main job (keeping anchors
18
+ * stable under collaborative edits).
19
+ */
20
+
21
+ import { Mark, mergeAttributes } from '@tiptap/core'
22
+
23
+ export interface CommentMarkOptions {
24
+ /** Extra HTML attributes attached to every rendered comment span. */
25
+ HTMLAttributes: Record<string, unknown>
26
+ /**
27
+ * Base CSS class. Defaults to `actuate-comment`; the side panel adds
28
+ * `actuate-comment--resolved` / `actuate-comment--detached` modifiers
29
+ * on top of this when it paints state-aware highlights.
30
+ */
31
+ className: string
32
+ }
33
+
34
+ declare module '@tiptap/core' {
35
+ interface Commands<ReturnType> {
36
+ comment: {
37
+ /** Mark the current selection with a comment id. */
38
+ setComment: (attrs: { commentId: string }) => ReturnType
39
+ /** Update the commentId on the active comment mark. */
40
+ updateComment: (attrs: { commentId: string }) => ReturnType
41
+ /** Remove all comment marks from the current selection. */
42
+ unsetComment: () => ReturnType
43
+ }
44
+ }
45
+ }
46
+
47
+ export const CommentMark = Mark.create<CommentMarkOptions>({
48
+ name: 'comment',
49
+ // Inline mark; we never want it on block-level nodes.
50
+ inclusive: false,
51
+ spanning: false,
52
+ // Allow the mark to be split when text is inserted at its boundaries —
53
+ // otherwise replies/edits in the middle of a comment would silently
54
+ // expand its scope.
55
+ excludes: '',
56
+
57
+ addOptions() {
58
+ return {
59
+ HTMLAttributes: {},
60
+ className: 'actuate-comment',
61
+ }
62
+ },
63
+
64
+ addAttributes() {
65
+ return {
66
+ commentId: {
67
+ default: null,
68
+ parseHTML: (element) => element.getAttribute('data-comment-id'),
69
+ renderHTML: (attributes) => {
70
+ if (!attributes.commentId) return {}
71
+ return { 'data-comment-id': attributes.commentId as string }
72
+ },
73
+ },
74
+ }
75
+ },
76
+
77
+ parseHTML() {
78
+ return [{ tag: 'span[data-comment-id]' }]
79
+ },
80
+
81
+ renderHTML({ HTMLAttributes }) {
82
+ const opts = this.options
83
+ return [
84
+ 'span',
85
+ mergeAttributes(opts.HTMLAttributes, HTMLAttributes, {
86
+ class: opts.className,
87
+ }),
88
+ 0,
89
+ ]
90
+ },
91
+
92
+ addCommands() {
93
+ return {
94
+ setComment:
95
+ (attrs) =>
96
+ ({ commands }) => {
97
+ if (!attrs?.commentId) return false
98
+ return commands.setMark(this.name, attrs)
99
+ },
100
+ updateComment:
101
+ (attrs) =>
102
+ ({ commands }) => {
103
+ if (!attrs?.commentId) return false
104
+ return commands.updateAttributes(this.name, attrs)
105
+ },
106
+ unsetComment:
107
+ () =>
108
+ ({ commands }) => {
109
+ return commands.unsetMark(this.name)
110
+ },
111
+ }
112
+ },
113
+ })
114
+
115
+ /**
116
+ * CSS the editor styles include to render the comment highlights. The
117
+ * `TipTapEditor` injects this so consumers don't need to import a CSS
118
+ * file separately.
119
+ */
120
+ export const COMMENT_MARK_STYLES = `
121
+ .actuate-comment {
122
+ background-color: rgba(250, 204, 21, 0.25);
123
+ border-bottom: 2px solid rgba(202, 138, 4, 0.65);
124
+ cursor: pointer;
125
+ }
126
+ .actuate-comment:hover {
127
+ background-color: rgba(250, 204, 21, 0.4);
128
+ }
129
+ .actuate-comment--resolved {
130
+ background-color: transparent;
131
+ border-bottom: 1px dotted rgba(107, 114, 128, 0.6);
132
+ }
133
+ .actuate-comment--active {
134
+ background-color: rgba(250, 204, 21, 0.55);
135
+ outline: 2px solid rgba(202, 138, 4, 0.9);
136
+ outline-offset: -1px;
137
+ }
138
+ `
@@ -0,0 +1,521 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import {
6
+ type Comment,
7
+ type CommentAnchor,
8
+ createComment as apiCreateComment,
9
+ deleteComment as apiDeleteComment,
10
+ groupCommentsIntoThreads,
11
+ listComments as apiListComments,
12
+ reopenComment as apiReopenComment,
13
+ resolveComment as apiResolveComment,
14
+ updateComment as apiUpdateComment,
15
+ type CommentThread,
16
+ type CommentOutcome,
17
+ } from '../lib/comments-client.js'
18
+
19
+ /**
20
+ * Side-panel view of the slice-4 comments API. Renders threaded comments
21
+ * for a document, lets the current user compose new threads and replies,
22
+ * and offers per-comment actions: edit, resolve / reopen, delete.
23
+ *
24
+ * The component is *pure I/O* against the comments-client module —
25
+ * anchor encoding/decoding, editor mark painting, and bubble-menu wiring
26
+ * are layered on top in `TipTapEditor.tsx` through the `comments` prop.
27
+ * Keeping the panel logic agnostic of TipTap means the unit tests can
28
+ * verify thread ordering / state transitions without spinning up an
29
+ * editor.
30
+ *
31
+ * Behaviour
32
+ * ─────────
33
+ * - Active comments (not resolved, not deleted) are shown by default.
34
+ * - A toggle reveals resolved threads in the same list, sorted by
35
+ * resolved-at descending so the most recently closed appears first.
36
+ * - Composer accepts free-form text. When `onComposeAnchor` returns a
37
+ * non-null anchor the new comment is bound to that range; otherwise
38
+ * it is a document-level comment.
39
+ * - The list refreshes after every successful mutation so optimistic
40
+ * state can't drift from the server.
41
+ *
42
+ * Errors fall through to the `onError` callback so the host shell can
43
+ * surface them with its own toast / banner system (we don't import
44
+ * sonner here to keep the component testable in plain JSDOM).
45
+ */
46
+ export interface CommentSidePanelProps {
47
+ documentId: string
48
+ /** Current user id — used to gate edit / delete affordances. */
49
+ currentUserId: string
50
+ /** Set to `true` to show admin-only affordances (delete others'). */
51
+ isAdmin?: boolean
52
+ /**
53
+ * Called when the user clicks "Comment on selection". Should return
54
+ * the encoded anchor for the active editor selection, or `null` for a
55
+ * doc-level comment. Wiring lives in `TipTapEditor` so this component
56
+ * stays editor-agnostic.
57
+ */
58
+ onComposeAnchor?: () => CommentAnchor | null
59
+ /**
60
+ * Called whenever the active comment changes (focus shift / open
61
+ * thread). The editor uses this to scroll the highlighted range into
62
+ * view and paint the `actuate-comment--active` modifier.
63
+ */
64
+ onActiveCommentChange?: (commentId: string | null) => void
65
+ /** Error sink — bubble up server failures to a toast / banner. */
66
+ onError?: (message: string) => void
67
+ /**
68
+ * Override the comments-client API. Tests inject a fake; real callers
69
+ * should leave this unset.
70
+ */
71
+ api?: CommentsApi
72
+ className?: string
73
+ }
74
+
75
+ /**
76
+ * Tiny adapter type that lets tests swap the comments-client module out.
77
+ * Mirrors the slice-4 client surface 1:1 — keeping it explicit prevents
78
+ * accidental drift when new endpoints are added later.
79
+ */
80
+ export interface CommentsApi {
81
+ list: (
82
+ documentId: string,
83
+ opts?: { includeResolved?: boolean },
84
+ ) => Promise<CommentOutcome<Comment[]>>
85
+ create: (input: {
86
+ documentId: string
87
+ body: string
88
+ anchor?: CommentAnchor | null
89
+ parentId?: string | null
90
+ }) => Promise<CommentOutcome<Comment>>
91
+ update: (id: string, body: string) => Promise<CommentOutcome<Comment>>
92
+ resolve: (id: string) => Promise<CommentOutcome<Comment>>
93
+ reopen: (id: string) => Promise<CommentOutcome<Comment>>
94
+ remove: (id: string) => Promise<CommentOutcome<Comment>>
95
+ }
96
+
97
+ const defaultApi: CommentsApi = {
98
+ list: (documentId, opts) => apiListComments(documentId, opts),
99
+ create: (input) => apiCreateComment(input),
100
+ update: (id, body) => apiUpdateComment(id, body),
101
+ resolve: (id) => apiResolveComment(id),
102
+ reopen: (id) => apiReopenComment(id),
103
+ remove: (id) => apiDeleteComment(id),
104
+ }
105
+
106
+ export function CommentSidePanel({
107
+ documentId,
108
+ currentUserId,
109
+ isAdmin = false,
110
+ onComposeAnchor,
111
+ onActiveCommentChange,
112
+ onError,
113
+ api = defaultApi,
114
+ className,
115
+ }: CommentSidePanelProps): React.JSX.Element {
116
+ const [comments, setComments] = useState<Comment[]>([])
117
+ const [includeResolved, setIncludeResolved] = useState(false)
118
+ const [loading, setLoading] = useState(true)
119
+ const [draft, setDraft] = useState('')
120
+ const [activeCommentId, setActiveCommentId] = useState<string | null>(null)
121
+ const mountedRef = useRef(true)
122
+ useEffect(() => {
123
+ return () => {
124
+ mountedRef.current = false
125
+ }
126
+ }, [])
127
+
128
+ const setActive = useCallback(
129
+ (id: string | null) => {
130
+ setActiveCommentId(id)
131
+ onActiveCommentChange?.(id)
132
+ },
133
+ [onActiveCommentChange],
134
+ )
135
+
136
+ const refresh = useCallback(async () => {
137
+ setLoading(true)
138
+ const out = await api.list(documentId, { includeResolved })
139
+ if (!mountedRef.current) return
140
+ if (!out.ok) {
141
+ onError?.(out.error)
142
+ setComments([])
143
+ } else {
144
+ setComments(out.result)
145
+ }
146
+ setLoading(false)
147
+ }, [api, documentId, includeResolved, onError])
148
+
149
+ useEffect(() => {
150
+ void refresh()
151
+ }, [refresh])
152
+
153
+ const threads = useMemo(() => groupCommentsIntoThreads(comments), [comments])
154
+
155
+ const handleCompose = useCallback(async () => {
156
+ const body = draft.trim()
157
+ if (!body) return
158
+ const anchor = onComposeAnchor?.() ?? null
159
+ const out = await api.create({ documentId, body, anchor })
160
+ if (!out.ok) {
161
+ onError?.(out.error)
162
+ return
163
+ }
164
+ setDraft('')
165
+ setActive(out.result.id)
166
+ await refresh()
167
+ }, [draft, onComposeAnchor, api, documentId, onError, setActive, refresh])
168
+
169
+ const handleReply = useCallback(
170
+ async (parentId: string, body: string) => {
171
+ const trimmed = body.trim()
172
+ if (!trimmed) return
173
+ const out = await api.create({ documentId, body: trimmed, parentId })
174
+ if (!out.ok) {
175
+ onError?.(out.error)
176
+ return
177
+ }
178
+ await refresh()
179
+ },
180
+ [api, documentId, onError, refresh],
181
+ )
182
+
183
+ const handleEdit = useCallback(
184
+ async (id: string, body: string) => {
185
+ const out = await api.update(id, body)
186
+ if (!out.ok) {
187
+ onError?.(out.error)
188
+ return false
189
+ }
190
+ await refresh()
191
+ return true
192
+ },
193
+ [api, onError, refresh],
194
+ )
195
+
196
+ const handleResolve = useCallback(
197
+ async (id: string, alreadyResolved: boolean) => {
198
+ const out = alreadyResolved ? await api.reopen(id) : await api.resolve(id)
199
+ if (!out.ok) {
200
+ onError?.(out.error)
201
+ return
202
+ }
203
+ await refresh()
204
+ },
205
+ [api, onError, refresh],
206
+ )
207
+
208
+ const handleDelete = useCallback(
209
+ async (id: string) => {
210
+ const out = await api.remove(id)
211
+ if (!out.ok) {
212
+ onError?.(out.error)
213
+ return
214
+ }
215
+ if (activeCommentId === id) setActive(null)
216
+ await refresh()
217
+ },
218
+ [api, onError, refresh, activeCommentId, setActive],
219
+ )
220
+
221
+ const wrapperClass = `flex h-full flex-col gap-3 border-l border-gray-200 bg-white p-3 ${className ?? ''}`
222
+
223
+ return (
224
+ <aside className={wrapperClass} data-testid="comment-side-panel">
225
+ <header className="flex items-center justify-between">
226
+ <h2 className="text-sm font-semibold text-gray-900">Comments</h2>
227
+ <label className="flex cursor-pointer items-center gap-1 text-xs text-gray-600">
228
+ <input
229
+ type="checkbox"
230
+ checked={includeResolved}
231
+ onChange={(e) => setIncludeResolved(e.target.checked)}
232
+ data-testid="include-resolved-toggle"
233
+ />
234
+ Show resolved
235
+ </label>
236
+ </header>
237
+
238
+ <section className="flex flex-col gap-1.5">
239
+ <textarea
240
+ className="w-full resize-none rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
241
+ rows={3}
242
+ placeholder="Write a comment…"
243
+ value={draft}
244
+ onChange={(e) => setDraft(e.target.value)}
245
+ data-testid="comment-composer"
246
+ />
247
+ <div className="flex justify-end">
248
+ <button
249
+ type="button"
250
+ className="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-500 disabled:opacity-50"
251
+ disabled={!draft.trim()}
252
+ onClick={handleCompose}
253
+ data-testid="comment-submit"
254
+ >
255
+ Comment
256
+ </button>
257
+ </div>
258
+ </section>
259
+
260
+ <section className="flex-1 overflow-y-auto" aria-busy={loading} data-testid="comment-list">
261
+ {loading ? (
262
+ <p className="text-xs text-gray-500">Loading…</p>
263
+ ) : threads.length === 0 ? (
264
+ <p className="text-xs text-gray-500">No comments yet.</p>
265
+ ) : (
266
+ <ul className="flex flex-col gap-2">
267
+ {threads.map((thread) => (
268
+ <CommentThreadView
269
+ key={thread.root.id}
270
+ thread={thread}
271
+ currentUserId={currentUserId}
272
+ isAdmin={isAdmin}
273
+ isActive={activeCommentId === thread.root.id}
274
+ onFocus={() => setActive(thread.root.id)}
275
+ onReply={(body) => handleReply(thread.root.id, body)}
276
+ onEdit={(id, body) => handleEdit(id, body)}
277
+ onToggleResolve={(id, alreadyResolved) => handleResolve(id, alreadyResolved)}
278
+ onDelete={(id) => handleDelete(id)}
279
+ />
280
+ ))}
281
+ </ul>
282
+ )}
283
+ </section>
284
+ </aside>
285
+ )
286
+ }
287
+
288
+ interface CommentThreadViewProps {
289
+ thread: CommentThread
290
+ currentUserId: string
291
+ isAdmin: boolean
292
+ isActive: boolean
293
+ onFocus: () => void
294
+ onReply: (body: string) => void
295
+ onEdit: (id: string, body: string) => Promise<boolean>
296
+ onToggleResolve: (id: string, alreadyResolved: boolean) => void
297
+ onDelete: (id: string) => void
298
+ }
299
+
300
+ function CommentThreadView({
301
+ thread,
302
+ currentUserId,
303
+ isAdmin,
304
+ isActive,
305
+ onFocus,
306
+ onReply,
307
+ onEdit,
308
+ onToggleResolve,
309
+ onDelete,
310
+ }: CommentThreadViewProps): React.JSX.Element {
311
+ const [replyDraft, setReplyDraft] = useState('')
312
+ const cardClass = `rounded border bg-gray-50 p-2 ${
313
+ isActive ? 'border-yellow-400 ring-1 ring-yellow-300' : 'border-gray-200'
314
+ }`
315
+
316
+ return (
317
+ <li
318
+ className={cardClass}
319
+ onClick={onFocus}
320
+ data-testid={`comment-thread-${thread.root.id}`}
321
+ data-active={isActive ? 'true' : 'false'}
322
+ >
323
+ <CommentRow
324
+ comment={thread.root}
325
+ currentUserId={currentUserId}
326
+ isAdmin={isAdmin}
327
+ onEdit={onEdit}
328
+ onToggleResolve={() => onToggleResolve(thread.root.id, thread.root.isResolved)}
329
+ onDelete={() => onDelete(thread.root.id)}
330
+ />
331
+ {thread.replies.length > 0 && (
332
+ <ul className="mt-2 flex flex-col gap-1 border-l-2 border-gray-200 pl-2">
333
+ {thread.replies.map((reply) => (
334
+ <li key={reply.id}>
335
+ <CommentRow
336
+ comment={reply}
337
+ currentUserId={currentUserId}
338
+ isAdmin={isAdmin}
339
+ onEdit={onEdit}
340
+ // Replies inherit the thread's resolve action; we only
341
+ // expose delete here.
342
+ onToggleResolve={null}
343
+ onDelete={() => onDelete(reply.id)}
344
+ />
345
+ </li>
346
+ ))}
347
+ </ul>
348
+ )}
349
+ <div className="mt-2 flex gap-1">
350
+ <input
351
+ className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
352
+ placeholder="Reply…"
353
+ value={replyDraft}
354
+ onChange={(e) => setReplyDraft(e.target.value)}
355
+ data-testid={`reply-input-${thread.root.id}`}
356
+ onClick={(e) => e.stopPropagation()}
357
+ />
358
+ <button
359
+ type="button"
360
+ className="rounded bg-gray-700 px-2 py-1 text-xs font-medium text-white hover:bg-gray-600 disabled:opacity-50"
361
+ disabled={!replyDraft.trim()}
362
+ onClick={(e) => {
363
+ e.stopPropagation()
364
+ onReply(replyDraft)
365
+ setReplyDraft('')
366
+ }}
367
+ data-testid={`reply-submit-${thread.root.id}`}
368
+ >
369
+ Reply
370
+ </button>
371
+ </div>
372
+ </li>
373
+ )
374
+ }
375
+
376
+ interface CommentRowProps {
377
+ comment: Comment
378
+ currentUserId: string
379
+ isAdmin: boolean
380
+ onEdit: (id: string, body: string) => Promise<boolean>
381
+ onToggleResolve: null | (() => void)
382
+ onDelete: () => void
383
+ }
384
+
385
+ function CommentRow({
386
+ comment,
387
+ currentUserId,
388
+ isAdmin,
389
+ onEdit,
390
+ onToggleResolve,
391
+ onDelete,
392
+ }: CommentRowProps): React.JSX.Element {
393
+ const [editing, setEditing] = useState(false)
394
+ const [draft, setDraft] = useState(comment.body)
395
+ useEffect(() => {
396
+ setDraft(comment.body)
397
+ }, [comment.body])
398
+
399
+ const canMutate = isAdmin || comment.userId === currentUserId
400
+ const meta = (
401
+ <div className="text-xs text-gray-500">
402
+ {comment.userId ?? 'deleted user'} · {formatRelative(comment.createdAt)}
403
+ {comment.isResolved ? ' · resolved' : null}
404
+ </div>
405
+ )
406
+
407
+ return (
408
+ <div className="flex flex-col gap-1">
409
+ {meta}
410
+ {editing ? (
411
+ <>
412
+ <textarea
413
+ className="w-full resize-none rounded border border-gray-300 p-1 text-sm"
414
+ rows={3}
415
+ value={draft}
416
+ onChange={(e) => setDraft(e.target.value)}
417
+ data-testid={`edit-textarea-${comment.id}`}
418
+ onClick={(e) => e.stopPropagation()}
419
+ />
420
+ <div className="flex gap-1">
421
+ <button
422
+ type="button"
423
+ className="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white"
424
+ onClick={async (e) => {
425
+ e.stopPropagation()
426
+ const ok = await onEdit(comment.id, draft)
427
+ if (ok) setEditing(false)
428
+ }}
429
+ data-testid={`edit-save-${comment.id}`}
430
+ >
431
+ Save
432
+ </button>
433
+ <button
434
+ type="button"
435
+ className="rounded border border-gray-300 px-2 py-1 text-xs"
436
+ onClick={(e) => {
437
+ e.stopPropagation()
438
+ setDraft(comment.body)
439
+ setEditing(false)
440
+ }}
441
+ data-testid={`edit-cancel-${comment.id}`}
442
+ >
443
+ Cancel
444
+ </button>
445
+ </div>
446
+ </>
447
+ ) : (
448
+ <p
449
+ className="text-sm whitespace-pre-wrap text-gray-900"
450
+ data-testid={`comment-body-${comment.id}`}
451
+ >
452
+ {comment.body}
453
+ </p>
454
+ )}
455
+ {!editing && (
456
+ <div className="flex flex-wrap gap-1 text-xs">
457
+ {canMutate && (
458
+ <button
459
+ type="button"
460
+ className="text-gray-600 hover:text-gray-900"
461
+ onClick={(e) => {
462
+ e.stopPropagation()
463
+ setEditing(true)
464
+ }}
465
+ data-testid={`edit-${comment.id}`}
466
+ >
467
+ Edit
468
+ </button>
469
+ )}
470
+ {onToggleResolve && (
471
+ <button
472
+ type="button"
473
+ className="text-gray-600 hover:text-gray-900"
474
+ onClick={(e) => {
475
+ e.stopPropagation()
476
+ onToggleResolve()
477
+ }}
478
+ data-testid={`resolve-${comment.id}`}
479
+ >
480
+ {comment.isResolved ? 'Reopen' : 'Resolve'}
481
+ </button>
482
+ )}
483
+ {canMutate && (
484
+ <button
485
+ type="button"
486
+ className="text-red-600 hover:text-red-700"
487
+ onClick={(e) => {
488
+ e.stopPropagation()
489
+ onDelete()
490
+ }}
491
+ data-testid={`delete-${comment.id}`}
492
+ >
493
+ Delete
494
+ </button>
495
+ )}
496
+ </div>
497
+ )}
498
+ </div>
499
+ )
500
+ }
501
+
502
+ /**
503
+ * Cheap "n seconds / minutes / hours / days ago" formatter. We avoid
504
+ * importing a full date-fns dependency just for this view; the panel is
505
+ * fine being approximate.
506
+ */
507
+ function formatRelative(iso: string): string {
508
+ const t = Date.parse(iso)
509
+ if (Number.isNaN(t)) return iso
510
+ const deltaSec = Math.max(0, Math.round((Date.now() - t) / 1000))
511
+ if (deltaSec < 60) return `${deltaSec}s ago`
512
+ const m = Math.round(deltaSec / 60)
513
+ if (m < 60) return `${m}m ago`
514
+ const h = Math.round(m / 60)
515
+ if (h < 24) return `${h}h ago`
516
+ const d = Math.round(h / 24)
517
+ return `${d}d ago`
518
+ }
519
+
520
+ // Re-export for tests that build their own CommentsApi.
521
+ export { formatRelative as _formatRelative }