@actuate-media/cms-admin 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +346 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +283 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +66 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +142 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +121 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +173 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +379 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +339 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +314 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/api.ts +11 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +292 -0
- 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 }
|