@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,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments REST client — thin, typed wrapper around the
|
|
3
|
+
* `/documents/:id/comments` + `/comments/:id` endpoints shipped in slice 4
|
|
4
|
+
* of Phase 3 (see `packages/cms-core/src/realtime/comments.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The client lives in `cms-admin` so the side panel, the bubble-menu
|
|
7
|
+
* "comment on selection" action, and any future MCP/AI hooks all share one
|
|
8
|
+
* request path, error model, and DTO shape.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the server contract:
|
|
11
|
+
* POST /documents/:documentId/comments -> { body, anchor?, parentId? }
|
|
12
|
+
* GET /documents/:documentId/comments?… -> { data: CommentDTO[] }
|
|
13
|
+
* PATCH /comments/:id -> { body }
|
|
14
|
+
* POST /comments/:id/resolve -> 200 with updated DTO
|
|
15
|
+
* POST /comments/:id/reopen -> 200 with updated DTO
|
|
16
|
+
* DELETE /comments/:id -> 200 with soft-deleted DTO
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { cmsApi } from './api.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Yjs relative-position anchor returned by the server. The encoding is
|
|
23
|
+
* intentionally opaque on the wire (Base64 of `Y.encodeRelativePosition`);
|
|
24
|
+
* `lib/comment-anchor.ts` handles the round-trip when slice 5 paints a
|
|
25
|
+
* highlight in the editor.
|
|
26
|
+
*/
|
|
27
|
+
export interface CommentAnchor {
|
|
28
|
+
from: string
|
|
29
|
+
to: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Public DTO returned by every comments endpoint. Mirrors `CommentDTO` in
|
|
34
|
+
* `@actuate-media/cms-core/realtime`; we duplicate the shape here so
|
|
35
|
+
* `cms-admin` doesn't take a hard dependency on the server-side service
|
|
36
|
+
* layer (which carries Prisma types we don't want pulled into the bundle).
|
|
37
|
+
*/
|
|
38
|
+
export interface Comment {
|
|
39
|
+
id: string
|
|
40
|
+
documentId: string
|
|
41
|
+
parentId: string | null
|
|
42
|
+
userId: string | null
|
|
43
|
+
body: string
|
|
44
|
+
anchor: CommentAnchor | null
|
|
45
|
+
resolvedAt: string | null
|
|
46
|
+
resolvedById: string | null
|
|
47
|
+
deletedAt: string | null
|
|
48
|
+
createdAt: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
isResolved: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CommentOutcome<T> =
|
|
54
|
+
| { ok: true; result: T }
|
|
55
|
+
| { ok: false; error: string; status: number }
|
|
56
|
+
|
|
57
|
+
export interface CreateCommentInput {
|
|
58
|
+
documentId: string
|
|
59
|
+
body: string
|
|
60
|
+
anchor?: CommentAnchor | null
|
|
61
|
+
parentId?: string | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ListCommentsOptions {
|
|
65
|
+
/** Default: false. Resolved threads are filtered out unless requested. */
|
|
66
|
+
includeResolved?: boolean
|
|
67
|
+
/** Default: false. Admin-only; the server rejects non-admin callers. */
|
|
68
|
+
includeDeleted?: boolean
|
|
69
|
+
/** Default: server-side cap (100). Hard upper bound is 200. */
|
|
70
|
+
pageSize?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encodeOptions(opts: ListCommentsOptions = {}): string {
|
|
74
|
+
const params = new URLSearchParams()
|
|
75
|
+
if (opts.includeResolved) params.set('includeResolved', 'true')
|
|
76
|
+
if (opts.includeDeleted) params.set('includeDeleted', 'true')
|
|
77
|
+
if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
|
|
78
|
+
const qs = params.toString()
|
|
79
|
+
return qs ? `?${qs}` : ''
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function failure(error: string, status: number): { ok: false; error: string; status: number } {
|
|
83
|
+
return { ok: false, error, status }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function envelope<T>(res: { data?: unknown; error?: string; status: number }): CommentOutcome<T> {
|
|
87
|
+
if (res.error || res.data === undefined) {
|
|
88
|
+
return failure(res.error ?? `Request failed (${res.status})`, res.status)
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, result: res.data as T }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a top-level comment or a reply. Pass `parentId` to thread the
|
|
95
|
+
* comment under an existing one — the server enforces single-level
|
|
96
|
+
* threading (replies cannot themselves be parents).
|
|
97
|
+
*/
|
|
98
|
+
export async function createComment(input: CreateCommentInput): Promise<CommentOutcome<Comment>> {
|
|
99
|
+
if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
|
|
100
|
+
return failure('documentId is required', 0)
|
|
101
|
+
}
|
|
102
|
+
if (typeof input.body !== 'string' || input.body.trim().length === 0) {
|
|
103
|
+
return failure('Comment cannot be empty', 0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const body: Record<string, unknown> = { body: input.body.trim() }
|
|
107
|
+
if (input.anchor) body.anchor = input.anchor
|
|
108
|
+
if (input.parentId) body.parentId = input.parentId
|
|
109
|
+
|
|
110
|
+
const res = await cmsApi<Comment>(`/documents/${encodeURIComponent(input.documentId)}/comments`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
})
|
|
114
|
+
return envelope<Comment>(res)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List comments for a document. Active comments (not resolved, not deleted)
|
|
119
|
+
* are returned by default; pass `includeResolved` to keep resolved threads
|
|
120
|
+
* in the response. The server rejects `includeDeleted` for non-admins.
|
|
121
|
+
*/
|
|
122
|
+
export async function listComments(
|
|
123
|
+
documentId: string,
|
|
124
|
+
opts: ListCommentsOptions = {},
|
|
125
|
+
): Promise<CommentOutcome<Comment[]>> {
|
|
126
|
+
if (typeof documentId !== 'string' || documentId.length === 0) {
|
|
127
|
+
return failure('documentId is required', 0)
|
|
128
|
+
}
|
|
129
|
+
const path = `/documents/${encodeURIComponent(documentId)}/comments${encodeOptions(opts)}`
|
|
130
|
+
const res = await cmsApi<Comment[]>(path, { method: 'GET' })
|
|
131
|
+
return envelope<Comment[]>(res)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Edit a comment body. Only the author or an admin is permitted. */
|
|
135
|
+
export async function updateComment(id: string, body: string): Promise<CommentOutcome<Comment>> {
|
|
136
|
+
if (!id) return failure('comment id is required', 0)
|
|
137
|
+
if (typeof body !== 'string' || body.trim().length === 0) {
|
|
138
|
+
return failure('Comment cannot be empty', 0)
|
|
139
|
+
}
|
|
140
|
+
const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
|
|
141
|
+
method: 'PATCH',
|
|
142
|
+
body: JSON.stringify({ body: body.trim() }),
|
|
143
|
+
})
|
|
144
|
+
return envelope<Comment>(res)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve a thread. Write-role or the author may resolve. */
|
|
148
|
+
export async function resolveComment(id: string): Promise<CommentOutcome<Comment>> {
|
|
149
|
+
if (!id) return failure('comment id is required', 0)
|
|
150
|
+
const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/resolve`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
body: '{}',
|
|
153
|
+
})
|
|
154
|
+
return envelope<Comment>(res)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Re-open a resolved thread. Same permission as resolve. */
|
|
158
|
+
export async function reopenComment(id: string): Promise<CommentOutcome<Comment>> {
|
|
159
|
+
if (!id) return failure('comment id is required', 0)
|
|
160
|
+
const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/reopen`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
body: '{}',
|
|
163
|
+
})
|
|
164
|
+
return envelope<Comment>(res)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Soft-delete a comment. The row is preserved so its replies don't lose
|
|
169
|
+
* their thread root — the server simply stops returning it from the list
|
|
170
|
+
* endpoint unless an admin asks for `includeDeleted`.
|
|
171
|
+
*/
|
|
172
|
+
export async function deleteComment(id: string): Promise<CommentOutcome<Comment>> {
|
|
173
|
+
if (!id) return failure('comment id is required', 0)
|
|
174
|
+
const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
|
|
175
|
+
method: 'DELETE',
|
|
176
|
+
})
|
|
177
|
+
return envelope<Comment>(res)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sort comments into thread groups suitable for rendering. Roots are
|
|
182
|
+
* returned in chronological order; replies are ordered the same way and
|
|
183
|
+
* attached to their root. Replies whose root is missing (e.g. the root was
|
|
184
|
+
* filtered out by `includeResolved=false`) are dropped — the UI can fetch
|
|
185
|
+
* them explicitly if it needs to render orphaned replies.
|
|
186
|
+
*/
|
|
187
|
+
export interface CommentThread {
|
|
188
|
+
root: Comment
|
|
189
|
+
replies: Comment[]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function groupCommentsIntoThreads(comments: readonly Comment[]): CommentThread[] {
|
|
193
|
+
const roots = new Map<string, CommentThread>()
|
|
194
|
+
const sorted = [...comments].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
195
|
+
|
|
196
|
+
for (const c of sorted) {
|
|
197
|
+
if (c.parentId === null) {
|
|
198
|
+
roots.set(c.id, { root: c, replies: [] })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const c of sorted) {
|
|
202
|
+
if (c.parentId !== null) {
|
|
203
|
+
const root = roots.get(c.parentId)
|
|
204
|
+
if (root) root.replies.push(c)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return Array.from(roots.values())
|
|
208
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications REST client — typed wrapper around the slice-6 endpoints
|
|
3
|
+
* (`/notifications`, `/notifications/unread-count`,
|
|
4
|
+
* `/notifications/:id/read`, `/notifications/read-all`).
|
|
5
|
+
*
|
|
6
|
+
* Lives in `cms-admin` so the bell, the toast layer, and any future
|
|
7
|
+
* background sync agent share one request path, error model, and DTO
|
|
8
|
+
* shape. The wire format mirrors `NotificationDTO` in
|
|
9
|
+
* `@actuate-media/cms-core/realtime/notifications.ts`; we duplicate the
|
|
10
|
+
* shape here to avoid taking a hard dependency on the server module
|
|
11
|
+
* (which carries Prisma types that don't belong in the admin bundle).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { cmsApi, getApiBase } from './api.js'
|
|
15
|
+
|
|
16
|
+
export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'
|
|
17
|
+
|
|
18
|
+
export interface CommentReplyPayload {
|
|
19
|
+
kind: 'comment_reply'
|
|
20
|
+
actorId: string | null
|
|
21
|
+
actorName: string | null
|
|
22
|
+
excerpt: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CommentResolvedPayload {
|
|
26
|
+
kind: 'comment_resolved'
|
|
27
|
+
actorId: string | null
|
|
28
|
+
actorName: string | null
|
|
29
|
+
rootExcerpt: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommentMentionPayload {
|
|
33
|
+
kind: 'comment_mention'
|
|
34
|
+
actorId: string | null
|
|
35
|
+
actorName: string | null
|
|
36
|
+
excerpt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type NotificationPayload =
|
|
40
|
+
| CommentReplyPayload
|
|
41
|
+
| CommentResolvedPayload
|
|
42
|
+
| CommentMentionPayload
|
|
43
|
+
|
|
44
|
+
export interface Notification {
|
|
45
|
+
id: string
|
|
46
|
+
userId: string
|
|
47
|
+
kind: NotificationKind
|
|
48
|
+
documentId: string | null
|
|
49
|
+
commentId: string | null
|
|
50
|
+
payload: NotificationPayload
|
|
51
|
+
readAt: string | null
|
|
52
|
+
createdAt: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type NotificationOutcome<T> =
|
|
56
|
+
| { ok: true; result: T }
|
|
57
|
+
| { ok: false; error: string; status: number }
|
|
58
|
+
|
|
59
|
+
export interface ListNotificationsOptions {
|
|
60
|
+
/** Default: false. When true the response includes already-read rows. */
|
|
61
|
+
includeRead?: boolean
|
|
62
|
+
/** Default: server-side cap. Hard upper bound is 200. */
|
|
63
|
+
pageSize?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function failure(error: string, status: number): { ok: false; error: string; status: number } {
|
|
67
|
+
return { ok: false, error, status }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function envelope<T>(res: {
|
|
71
|
+
data?: unknown
|
|
72
|
+
error?: string
|
|
73
|
+
status: number
|
|
74
|
+
}): NotificationOutcome<T> {
|
|
75
|
+
if (res.error || res.data === undefined) {
|
|
76
|
+
return failure(res.error ?? `Request failed (${res.status})`, res.status)
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, result: res.data as T }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildQuery(opts: ListNotificationsOptions = {}): string {
|
|
82
|
+
const params = new URLSearchParams()
|
|
83
|
+
if (opts.includeRead) params.set('includeRead', 'true')
|
|
84
|
+
if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
|
|
85
|
+
const qs = params.toString()
|
|
86
|
+
return qs ? `?${qs}` : ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function listNotifications(
|
|
90
|
+
opts: ListNotificationsOptions = {},
|
|
91
|
+
): Promise<NotificationOutcome<Notification[]>> {
|
|
92
|
+
const res = await cmsApi<Notification[]>(`/notifications${buildQuery(opts)}`, {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
})
|
|
95
|
+
return envelope<Notification[]>(res)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function unreadCount(): Promise<NotificationOutcome<number>> {
|
|
99
|
+
const res = await cmsApi<{ count: number }>(`/notifications/unread-count`, {
|
|
100
|
+
method: 'GET',
|
|
101
|
+
})
|
|
102
|
+
if (res.error || !res.data) {
|
|
103
|
+
return failure(res.error ?? `Request failed (${res.status})`, res.status)
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, result: res.data.count }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function markNotificationRead(id: string): Promise<NotificationOutcome<Notification>> {
|
|
109
|
+
if (!id) return failure('notification id is required', 0)
|
|
110
|
+
const res = await cmsApi<Notification>(`/notifications/${encodeURIComponent(id)}/read`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: '{}',
|
|
113
|
+
})
|
|
114
|
+
return envelope<Notification>(res)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function markAllNotificationsRead(): Promise<NotificationOutcome<number>> {
|
|
118
|
+
const res = await cmsApi<{ count: number }>(`/notifications/read-all`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
body: '{}',
|
|
121
|
+
})
|
|
122
|
+
if (res.error || !res.data) {
|
|
123
|
+
return failure(res.error ?? `Request failed (${res.status})`, res.status)
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, result: res.data.count }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Subscribe to the per-user SSE push channel that ships notifications
|
|
130
|
+
* the instant they're persisted server-side. Replaces the 30-second
|
|
131
|
+
* poll loop in the notification bell.
|
|
132
|
+
*
|
|
133
|
+
* The browser's native `EventSource` handles reconnection for us — on
|
|
134
|
+
* a server-initiated close (the stream caps at ~4 minutes by design),
|
|
135
|
+
* the connection re-opens automatically. We DO NOT abort the poll
|
|
136
|
+
* fallback in callers: SSE is a latency-win, the poll is what keeps
|
|
137
|
+
* multi-node deployments correct (each node only pushes to its own
|
|
138
|
+
* subscribers).
|
|
139
|
+
*
|
|
140
|
+
* Returns a `close()` function that callers MUST invoke on unmount —
|
|
141
|
+
* otherwise the browser keeps the connection open and we leak event
|
|
142
|
+
* listeners.
|
|
143
|
+
*
|
|
144
|
+
* `EventSource` itself isn't available in the JSDOM test environment;
|
|
145
|
+
* callers can inject a `factory` to substitute a test double. In
|
|
146
|
+
* production we fall back to the global `EventSource` when no factory
|
|
147
|
+
* is supplied.
|
|
148
|
+
*/
|
|
149
|
+
export interface NotificationStreamOptions {
|
|
150
|
+
/** Called for every `event: notification` frame. */
|
|
151
|
+
onNotification: (n: Notification) => void
|
|
152
|
+
/** Called once on the `event: ready` frame. */
|
|
153
|
+
onReady?: (payload: { userId: string }) => void
|
|
154
|
+
/** Called on connection errors. Useful for falling back to the poll loop. */
|
|
155
|
+
onError?: (event: Event) => void
|
|
156
|
+
/**
|
|
157
|
+
* Optional factory so unit tests can inject a fake `EventSource`,
|
|
158
|
+
* or a null sentinel when the environment lacks one (SSR, JSDOM
|
|
159
|
+
* without a polyfill). Production code can rely on the default
|
|
160
|
+
* (global `EventSource`).
|
|
161
|
+
*/
|
|
162
|
+
factory?: (url: string) => EventSourceLike | null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Minimal subset of the `EventSource` interface our subscriber relies
|
|
167
|
+
* on. Browsers ship the full DOM type; the admin only needs add/remove
|
|
168
|
+
* event listeners plus `close()`.
|
|
169
|
+
*/
|
|
170
|
+
export interface EventSourceLike {
|
|
171
|
+
addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
|
|
172
|
+
removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
|
|
173
|
+
close: () => void
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface NotificationStreamHandle {
|
|
177
|
+
/** Tear down the EventSource and stop receiving events. Idempotent. */
|
|
178
|
+
close: () => void
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function subscribeNotificationStream(
|
|
182
|
+
opts: NotificationStreamOptions,
|
|
183
|
+
): NotificationStreamHandle {
|
|
184
|
+
// The `cmsApi()` helper handles base-path / origin resolution for
|
|
185
|
+
// fetch calls, but EventSource takes a raw URL. We mirror its
|
|
186
|
+
// logic locally — `/api/cms/notifications/stream` is correct for
|
|
187
|
+
// both the local dev server and any production deploy that mounts
|
|
188
|
+
// the CMS API at the default prefix. Custom prefixes are handled by
|
|
189
|
+
// the consumer override (factory).
|
|
190
|
+
// Resolve against `setApiBase()` so a consumer mounting the CMS at
|
|
191
|
+
// a non-default prefix (e.g. `/cms-api`) still hits the right SSE
|
|
192
|
+
// endpoint — `EventSource` doesn't go through `cmsApi()`, so we
|
|
193
|
+
// can't rely on its url-construction logic.
|
|
194
|
+
const url = `${getApiBase()}/notifications/stream`
|
|
195
|
+
const make = opts.factory ?? defaultEventSourceFactory
|
|
196
|
+
const source = make(url)
|
|
197
|
+
if (!source) {
|
|
198
|
+
// No EventSource available (SSR? JSDOM without polyfill? Or the
|
|
199
|
+
// injected factory deliberately returns null in tests). Return a
|
|
200
|
+
// no-op handle so callers can compose without conditional logic.
|
|
201
|
+
return { close: () => {} }
|
|
202
|
+
}
|
|
203
|
+
let closed = false
|
|
204
|
+
|
|
205
|
+
const onReady = (event: MessageEvent | Event) => {
|
|
206
|
+
if (!opts.onReady) return
|
|
207
|
+
const data = parseEventData(event)
|
|
208
|
+
if (data && typeof (data as { userId?: unknown }).userId === 'string') {
|
|
209
|
+
opts.onReady({ userId: (data as { userId: string }).userId })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const onNotification = (event: MessageEvent | Event) => {
|
|
213
|
+
const data = parseEventData(event)
|
|
214
|
+
if (data && isNotification(data)) {
|
|
215
|
+
opts.onNotification(data)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const onError = (event: Event) => {
|
|
219
|
+
opts.onError?.(event)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
source.addEventListener('ready', onReady)
|
|
223
|
+
source.addEventListener('notification', onNotification)
|
|
224
|
+
source.addEventListener('error', onError)
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
close: () => {
|
|
228
|
+
if (closed) return
|
|
229
|
+
closed = true
|
|
230
|
+
source.removeEventListener?.('ready', onReady)
|
|
231
|
+
source.removeEventListener?.('notification', onNotification)
|
|
232
|
+
source.removeEventListener?.('error', onError)
|
|
233
|
+
source.close()
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function defaultEventSourceFactory(url: string): EventSourceLike | null {
|
|
239
|
+
if (typeof EventSource === 'undefined') return null
|
|
240
|
+
// `withCredentials: true` so the admin session cookie travels with
|
|
241
|
+
// the connection upgrade — every notification stream requires auth
|
|
242
|
+
// and the same-origin cookie is the only credential we trust here.
|
|
243
|
+
return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseEventData(event: MessageEvent | Event): unknown {
|
|
247
|
+
// EventSource emits MessageEvents with `data: string`. Plain
|
|
248
|
+
// `Event` (e.g. the `error` event) has no payload; ignore.
|
|
249
|
+
const data = (event as MessageEvent).data
|
|
250
|
+
if (typeof data !== 'string') return null
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(data)
|
|
253
|
+
} catch {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isNotification(value: unknown): value is Notification {
|
|
259
|
+
if (!value || typeof value !== 'object') return false
|
|
260
|
+
const v = value as Record<string, unknown>
|
|
261
|
+
// Defensive: don't trust the wire blindly — verify the discriminant
|
|
262
|
+
// properties our consumers will read. A drift in the server-side
|
|
263
|
+
// wire format must surface as "missing fields" in the test suite,
|
|
264
|
+
// not as runtime errors deep in the bell's render path.
|
|
265
|
+
return (
|
|
266
|
+
typeof v.id === 'string' &&
|
|
267
|
+
typeof v.userId === 'string' &&
|
|
268
|
+
typeof v.createdAt === 'string' &&
|
|
269
|
+
!!v.payload &&
|
|
270
|
+
typeof (v.payload as { kind?: unknown }).kind === 'string'
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Cheap single-line summary for the bell list — the server gives us a
|
|
276
|
+
* structured payload, but the dropdown wants one human-readable string
|
|
277
|
+
* per row. Falls back to `Notification` when an unknown kind sneaks in
|
|
278
|
+
* (defensive against future server-side additions).
|
|
279
|
+
*/
|
|
280
|
+
export function notificationSummary(notification: Notification): string {
|
|
281
|
+
const actor = notification.payload.actorName ?? 'Someone'
|
|
282
|
+
switch (notification.payload.kind) {
|
|
283
|
+
case 'comment_reply':
|
|
284
|
+
return `${actor} replied: "${notification.payload.excerpt}"`
|
|
285
|
+
case 'comment_resolved':
|
|
286
|
+
return `${actor} resolved your thread: "${notification.payload.rootExcerpt}"`
|
|
287
|
+
case 'comment_mention':
|
|
288
|
+
return `${actor} mentioned you: "${notification.payload.excerpt}"`
|
|
289
|
+
default:
|
|
290
|
+
return 'Notification'
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { COAUTHOR_ACTIONS, isCoauthorAction, type CoauthorAction } from './coauthor-client.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cmd+K slash-command parsing for the AI co-author.
|
|
5
|
+
*
|
|
6
|
+
* Recognized syntax (case-insensitive, leading whitespace tolerated):
|
|
7
|
+
*
|
|
8
|
+
* /ai <action> [text...]
|
|
9
|
+
*
|
|
10
|
+
* `<action>` is one of `COAUTHOR_ACTIONS`. `text` is the payload handed to
|
|
11
|
+
* `/ai/coauthor`; it may be empty while the user is still typing.
|
|
12
|
+
*
|
|
13
|
+
* The parser is pure so we can unit-test every edge case (case folding,
|
|
14
|
+
* trailing whitespace, partial input, unknown actions) without spinning up
|
|
15
|
+
* `cmdk` or the rest of the palette.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface AiSlashCommand {
|
|
19
|
+
action: CoauthorAction
|
|
20
|
+
text: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SLASH_HEAD = /^\s*\/\s*ai(?:\s+|$)/i
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the matched `{ action, text }` when `input` is a complete
|
|
27
|
+
* slash command (`/ai rewrite ...`), or `null` otherwise. `text` may be
|
|
28
|
+
* empty when the user has typed `/ai rewrite` with no payload yet.
|
|
29
|
+
*/
|
|
30
|
+
export function parseAiSlashCommand(input: string): AiSlashCommand | null {
|
|
31
|
+
if (typeof input !== 'string') return null
|
|
32
|
+
if (!SLASH_HEAD.test(input)) return null
|
|
33
|
+
const after = input.replace(SLASH_HEAD, '')
|
|
34
|
+
if (after.length === 0) return null
|
|
35
|
+
|
|
36
|
+
const firstWhitespace = after.search(/\s/)
|
|
37
|
+
const head = (firstWhitespace === -1 ? after : after.slice(0, firstWhitespace)).toLowerCase()
|
|
38
|
+
if (!isCoauthorAction(head)) return null
|
|
39
|
+
|
|
40
|
+
const tail = firstWhitespace === -1 ? '' : after.slice(firstWhitespace + 1)
|
|
41
|
+
return { action: head, text: tail.trim() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true when `input` looks like the start of an AI slash command
|
|
46
|
+
* — even when the action isn't typed yet (`/`, `/a`, `/ai`, `/ai re`).
|
|
47
|
+
* The palette uses this to switch into "AI mode" (hide nav results,
|
|
48
|
+
* show the four action suggestions).
|
|
49
|
+
*/
|
|
50
|
+
export function isAiSlashCommandInput(input: string): boolean {
|
|
51
|
+
if (typeof input !== 'string') return false
|
|
52
|
+
const trimmed = input.trim()
|
|
53
|
+
if (trimmed.length === 0) return false
|
|
54
|
+
if (trimmed === '/') return true
|
|
55
|
+
return /^\/\s*a(i\b|i?[a-z]*|)?(\s.*)?$/i.test(trimmed)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ordered list of actions to show in AI mode. When the user has typed a
|
|
60
|
+
* valid command, that command's action is hoisted to the top so it gets
|
|
61
|
+
* the default keyboard focus (and Enter runs it).
|
|
62
|
+
*/
|
|
63
|
+
export function orderedAiActions(input: string): CoauthorAction[] {
|
|
64
|
+
const parsed = parseAiSlashCommand(input)
|
|
65
|
+
if (!parsed) return [...COAUTHOR_ACTIONS]
|
|
66
|
+
return [parsed.action, ...COAUTHOR_ACTIONS.filter((a) => a !== parsed.action)]
|
|
67
|
+
}
|