@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
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Monitor,
|
|
6
|
+
Tablet,
|
|
7
|
+
Smartphone,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
ExternalLink,
|
|
10
|
+
X,
|
|
11
|
+
Wifi,
|
|
12
|
+
WifiOff,
|
|
13
|
+
} from 'lucide-react'
|
|
5
14
|
import { cmsApi } from '../lib/api.js'
|
|
6
15
|
|
|
7
16
|
interface LivePreviewProps {
|
|
@@ -13,6 +22,7 @@ interface LivePreviewProps {
|
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
type Viewport = 'desktop' | 'tablet' | 'mobile'
|
|
25
|
+
type StreamStatus = 'idle' | 'connecting' | 'connected' | 'offline'
|
|
16
26
|
|
|
17
27
|
const VIEWPORT_WIDTHS: Record<Viewport, string> = {
|
|
18
28
|
desktop: '100%',
|
|
@@ -20,6 +30,23 @@ const VIEWPORT_WIDTHS: Record<Viewport, string> = {
|
|
|
20
30
|
mobile: '375px',
|
|
21
31
|
}
|
|
22
32
|
|
|
33
|
+
const PREVIEW_STREAM_PATH = '/api/cms/preview/stream'
|
|
34
|
+
|
|
35
|
+
function postToIframe(
|
|
36
|
+
iframe: HTMLIFrameElement | null,
|
|
37
|
+
src: string | null,
|
|
38
|
+
data: Record<string, unknown>,
|
|
39
|
+
): void {
|
|
40
|
+
if (!iframe?.contentWindow || !src) return
|
|
41
|
+
let targetOrigin: string
|
|
42
|
+
try {
|
|
43
|
+
targetOrigin = new URL(src).origin
|
|
44
|
+
} catch {
|
|
45
|
+
targetOrigin = window.location.origin
|
|
46
|
+
}
|
|
47
|
+
iframe.contentWindow.postMessage({ type: 'actuate-preview-update', data }, targetOrigin)
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
export function LivePreview({
|
|
24
51
|
collection,
|
|
25
52
|
documentId,
|
|
@@ -31,40 +58,89 @@ export function LivePreview({
|
|
|
31
58
|
const [viewport, setViewport] = useState<Viewport>('desktop')
|
|
32
59
|
const [loading, setLoading] = useState(true)
|
|
33
60
|
const [previewSrc, setPreviewSrc] = useState<string | null>(null)
|
|
61
|
+
const [previewToken, setPreviewToken] = useState<string | null>(null)
|
|
62
|
+
const [streamStatus, setStreamStatus] = useState<StreamStatus>('idle')
|
|
34
63
|
|
|
35
64
|
useEffect(() => {
|
|
36
65
|
if (!documentId || !previewUrl) {
|
|
37
66
|
setPreviewSrc(null)
|
|
67
|
+
setPreviewToken(null)
|
|
38
68
|
return
|
|
39
69
|
}
|
|
40
70
|
|
|
71
|
+
let cancelled = false
|
|
72
|
+
|
|
41
73
|
async function fetchToken() {
|
|
42
74
|
const res = await cmsApi<{ token: string }>('/preview/token', {
|
|
43
75
|
method: 'POST',
|
|
44
76
|
body: JSON.stringify({ collection, documentId }),
|
|
45
77
|
})
|
|
78
|
+
if (cancelled) return
|
|
46
79
|
if (res.data?.token) {
|
|
47
80
|
const sep = previewUrl!.includes('?') ? '&' : '?'
|
|
81
|
+
setPreviewToken(res.data.token)
|
|
48
82
|
setPreviewSrc(`${previewUrl}${sep}preview=true#token=${res.data.token}`)
|
|
49
83
|
}
|
|
50
84
|
}
|
|
51
85
|
|
|
52
86
|
fetchToken()
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true
|
|
89
|
+
}
|
|
53
90
|
}, [collection, documentId, previewUrl])
|
|
54
91
|
|
|
55
92
|
useEffect(() => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
postToIframe(iframeRef.current, previewSrc, values)
|
|
94
|
+
}, [values, previewSrc])
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* SSE pipe — when the server pushes a draft update for the same
|
|
98
|
+
* `{collection, documentId}`, forward the new `values` straight to the
|
|
99
|
+
* iframe via `postMessage`. The bus key matches the token's payload, so
|
|
100
|
+
* a second admin session updating the same draft (e.g. an AI agent)
|
|
101
|
+
* appears live in this preview without polling.
|
|
102
|
+
*/
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
|
|
105
|
+
if (!documentId || !previewToken) {
|
|
106
|
+
setStreamStatus('idle')
|
|
107
|
+
return
|
|
62
108
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
setStreamStatus('connecting')
|
|
110
|
+
const source = new EventSource(
|
|
111
|
+
`${PREVIEW_STREAM_PATH}?token=${encodeURIComponent(previewToken)}`,
|
|
112
|
+
{ withCredentials: true },
|
|
66
113
|
)
|
|
67
|
-
|
|
114
|
+
|
|
115
|
+
const onReady = () => setStreamStatus('connected')
|
|
116
|
+
const onUpdate = (event: MessageEvent<string>) => {
|
|
117
|
+
try {
|
|
118
|
+
const payload = JSON.parse(event.data) as { values?: Record<string, unknown> }
|
|
119
|
+
if (payload.values && typeof payload.values === 'object') {
|
|
120
|
+
postToIframe(iframeRef.current, previewSrc, payload.values)
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore malformed events — the SSE wire format guarantees JSON,
|
|
124
|
+
// but a future schema bump shouldn't break this consumer.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const onError = () => setStreamStatus('offline')
|
|
128
|
+
const onOpen = () => setStreamStatus((s) => (s === 'offline' ? 'connecting' : s))
|
|
129
|
+
|
|
130
|
+
source.addEventListener('ready', onReady)
|
|
131
|
+
source.addEventListener('update', onUpdate)
|
|
132
|
+
source.addEventListener('error', onError)
|
|
133
|
+
source.addEventListener('open', onOpen)
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
source.removeEventListener('ready', onReady)
|
|
137
|
+
source.removeEventListener('update', onUpdate)
|
|
138
|
+
source.removeEventListener('error', onError)
|
|
139
|
+
source.removeEventListener('open', onOpen)
|
|
140
|
+
source.close()
|
|
141
|
+
setStreamStatus('idle')
|
|
142
|
+
}
|
|
143
|
+
}, [documentId, previewToken, previewSrc])
|
|
68
144
|
|
|
69
145
|
const handleRefresh = useCallback(() => {
|
|
70
146
|
if (iframeRef.current) {
|
|
@@ -112,6 +188,36 @@ export function LivePreview({
|
|
|
112
188
|
<Icon className="h-4 w-4" />
|
|
113
189
|
</button>
|
|
114
190
|
))}
|
|
191
|
+
<span
|
|
192
|
+
className="ml-2 inline-flex items-center gap-1 rounded-full bg-white px-2 py-0.5 text-[10px] font-medium text-gray-600"
|
|
193
|
+
title={
|
|
194
|
+
streamStatus === 'connected'
|
|
195
|
+
? 'Live preview is streaming updates from the server.'
|
|
196
|
+
: streamStatus === 'connecting'
|
|
197
|
+
? 'Connecting to the live preview stream…'
|
|
198
|
+
: streamStatus === 'offline'
|
|
199
|
+
? 'Live stream disconnected. Updates from this admin still work; cross-session updates will reconnect automatically.'
|
|
200
|
+
: 'Live preview stream idle.'
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
{streamStatus === 'connected' ? (
|
|
204
|
+
<>
|
|
205
|
+
<Wifi className="h-3 w-3 text-green-600" aria-hidden /> Live
|
|
206
|
+
</>
|
|
207
|
+
) : streamStatus === 'connecting' ? (
|
|
208
|
+
<>
|
|
209
|
+
<Wifi className="h-3 w-3 animate-pulse text-blue-500" aria-hidden /> Connecting
|
|
210
|
+
</>
|
|
211
|
+
) : streamStatus === 'offline' ? (
|
|
212
|
+
<>
|
|
213
|
+
<WifiOff className="h-3 w-3 text-amber-500" aria-hidden /> Reconnecting
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
<>
|
|
217
|
+
<WifiOff className="h-3 w-3 text-gray-400" aria-hidden /> Idle
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</span>
|
|
115
221
|
</div>
|
|
116
222
|
<div className="flex items-center gap-1">
|
|
117
223
|
<button
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Notification,
|
|
7
|
+
type NotificationOutcome,
|
|
8
|
+
type NotificationStreamHandle,
|
|
9
|
+
type NotificationStreamOptions,
|
|
10
|
+
listNotifications as defaultListNotifications,
|
|
11
|
+
markAllNotificationsRead as defaultMarkAllRead,
|
|
12
|
+
markNotificationRead as defaultMarkRead,
|
|
13
|
+
notificationSummary,
|
|
14
|
+
subscribeNotificationStream as defaultSubscribeStream,
|
|
15
|
+
unreadCount as defaultUnreadCount,
|
|
16
|
+
} from '../lib/notifications-client.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Top-bar notification bell + dropdown panel. Slice 6 of Phase 3
|
|
20
|
+
* surfaces every collaboration event that affects the current user —
|
|
21
|
+
* comment replies, resolves, and mentions — without forcing the
|
|
22
|
+
* editor to round-trip through the comments side panel.
|
|
23
|
+
*
|
|
24
|
+
* Design choices:
|
|
25
|
+
*
|
|
26
|
+
* - **SSE push, with poll fallback (Phase 7).** A persistent
|
|
27
|
+
* `EventSource` subscribes to `/api/cms/notifications/stream` for
|
|
28
|
+
* instant delivery; new notifications bump the badge immediately
|
|
29
|
+
* instead of after the next 30 s poll tick. The poll loop is kept
|
|
30
|
+
* alive at a back-pressure-friendly cadence (default 60 s — twice
|
|
31
|
+
* the legacy 30 s value because SSE handles the common case) and
|
|
32
|
+
* acts as a backstop for:
|
|
33
|
+
* 1. multi-node deployments where the in-process bus only
|
|
34
|
+
* publishes to subscribers on the same node, and
|
|
35
|
+
* 2. proxies / browsers that silently drop the EventSource
|
|
36
|
+
* without firing `onerror`.
|
|
37
|
+
* - **Optimistic read state.** When the user clicks a notification we
|
|
38
|
+
* mark it read locally before the server confirms — so the bell
|
|
39
|
+
* counter feels instant. Server failures roll the state back via
|
|
40
|
+
* the `onError` toast.
|
|
41
|
+
* - **API injection.** The dependency-injection seam matches the rest
|
|
42
|
+
* of the admin (`CommentSidePanel.api`) so unit tests can substitute
|
|
43
|
+
* in-memory fakes — including the EventSource subscriber, which
|
|
44
|
+
* isn't available in JSDOM by default.
|
|
45
|
+
*/
|
|
46
|
+
export interface NotificationsApi {
|
|
47
|
+
list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
|
|
48
|
+
unread: () => Promise<NotificationOutcome<number>>
|
|
49
|
+
markRead: (id: string) => Promise<NotificationOutcome<Notification>>
|
|
50
|
+
markAllRead: () => Promise<NotificationOutcome<number>>
|
|
51
|
+
/**
|
|
52
|
+
* Open a per-user SSE subscription. Implementations MUST always
|
|
53
|
+
* return a handle — even in environments without `EventSource`
|
|
54
|
+
* (SSR, JSDOM without a polyfill), where a no-op `{ close() {} }`
|
|
55
|
+
* is the right answer. The bell's effect calls `handle.close()`
|
|
56
|
+
* unconditionally on unmount; a `null` return would crash.
|
|
57
|
+
* Omitting the member entirely is the supported way to opt out of
|
|
58
|
+
* SSE (the bell then runs poll-only).
|
|
59
|
+
*/
|
|
60
|
+
subscribe?: (opts: NotificationStreamOptions) => NotificationStreamHandle
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultApi: NotificationsApi = {
|
|
64
|
+
list: (opts) => defaultListNotifications(opts),
|
|
65
|
+
unread: () => defaultUnreadCount(),
|
|
66
|
+
markRead: (id) => defaultMarkRead(id),
|
|
67
|
+
markAllRead: () => defaultMarkAllRead(),
|
|
68
|
+
subscribe: (opts) => defaultSubscribeStream(opts),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface NotificationBellProps {
|
|
72
|
+
/**
|
|
73
|
+
* Polling cadence in milliseconds for the fallback loop. Default
|
|
74
|
+
* 60,000 (twice the pre-SSE value — push handles the common case).
|
|
75
|
+
* Pass 0 to disable polling entirely (relies on SSE only — only
|
|
76
|
+
* safe on single-node deployments).
|
|
77
|
+
*/
|
|
78
|
+
pollIntervalMs?: number
|
|
79
|
+
/** Optional click hook — called with the notification when a row is clicked. */
|
|
80
|
+
onSelect?: (notification: Notification) => void
|
|
81
|
+
/** Error sink — bubble server failures to a toast / banner. */
|
|
82
|
+
onError?: (message: string) => void
|
|
83
|
+
/** Override the API client (tests). */
|
|
84
|
+
api?: NotificationsApi
|
|
85
|
+
className?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function NotificationBell({
|
|
89
|
+
pollIntervalMs = 60_000,
|
|
90
|
+
onSelect,
|
|
91
|
+
onError,
|
|
92
|
+
api = defaultApi,
|
|
93
|
+
className,
|
|
94
|
+
}: NotificationBellProps): React.JSX.Element {
|
|
95
|
+
const [open, setOpen] = useState(false)
|
|
96
|
+
const [unread, setUnread] = useState(0)
|
|
97
|
+
const [items, setItems] = useState<Notification[]>([])
|
|
98
|
+
const [loading, setLoading] = useState(false)
|
|
99
|
+
const mountedRef = useRef(true)
|
|
100
|
+
// Set of notification IDs we've already rendered. The SSE handler
|
|
101
|
+
// consults this synchronously so duplicate deliveries (REST refresh
|
|
102
|
+
// + SSE push, or two SSE retries) don't double-bump the badge.
|
|
103
|
+
// Kept in sync with `items` via an effect — the items state is the
|
|
104
|
+
// source of truth; this ref is a derived O(1) lookup index.
|
|
105
|
+
const seenIdsRef = useRef<Set<string>>(new Set())
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
seenIdsRef.current = new Set(items.map((n) => n.id))
|
|
108
|
+
}, [items])
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
return () => {
|
|
111
|
+
mountedRef.current = false
|
|
112
|
+
}
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
const refreshUnread = useCallback(async () => {
|
|
116
|
+
const out = await api.unread()
|
|
117
|
+
if (!mountedRef.current) return
|
|
118
|
+
if (!out.ok) {
|
|
119
|
+
onError?.(out.error)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
setUnread(out.result)
|
|
123
|
+
}, [api, onError])
|
|
124
|
+
|
|
125
|
+
const refreshList = useCallback(
|
|
126
|
+
async (includeRead = false) => {
|
|
127
|
+
setLoading(true)
|
|
128
|
+
const out = await api.list({ includeRead })
|
|
129
|
+
if (!mountedRef.current) return
|
|
130
|
+
if (!out.ok) {
|
|
131
|
+
onError?.(out.error)
|
|
132
|
+
setItems([])
|
|
133
|
+
} else {
|
|
134
|
+
setItems(out.result)
|
|
135
|
+
}
|
|
136
|
+
setLoading(false)
|
|
137
|
+
},
|
|
138
|
+
[api, onError],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
void refreshUnread()
|
|
143
|
+
if (!pollIntervalMs) return
|
|
144
|
+
const id = setInterval(() => void refreshUnread(), pollIntervalMs)
|
|
145
|
+
return () => clearInterval(id)
|
|
146
|
+
}, [refreshUnread, pollIntervalMs])
|
|
147
|
+
|
|
148
|
+
// SSE push subscription. Bumps the badge + prepends the new row to
|
|
149
|
+
// the open dropdown the instant the server publishes. Falls back
|
|
150
|
+
// silently when the api/runtime doesn't support EventSource — the
|
|
151
|
+
// poll loop above keeps the bell correct in that case.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!api.subscribe) return
|
|
154
|
+
const handle = api.subscribe({
|
|
155
|
+
onNotification: (n) => {
|
|
156
|
+
if (!mountedRef.current) return
|
|
157
|
+
// Defensive: SSE events can race with REST refreshes (or with
|
|
158
|
+
// their own retries). The seen-id ref is updated
|
|
159
|
+
// synchronously so a second delivery of the same row is a
|
|
160
|
+
// no-op for BOTH `items` and the unread badge — guarding only
|
|
161
|
+
// setItems would still let the counter drift.
|
|
162
|
+
if (seenIdsRef.current.has(n.id)) return
|
|
163
|
+
seenIdsRef.current.add(n.id)
|
|
164
|
+
setItems((arr) => [n, ...arr])
|
|
165
|
+
if (!n.readAt) setUnread((v) => v + 1)
|
|
166
|
+
},
|
|
167
|
+
onError: () => {
|
|
168
|
+
// Don't surface every transient SSE disconnect as a UI error —
|
|
169
|
+
// the browser auto-reconnects, and the poll loop keeps state
|
|
170
|
+
// converging. We deliberately stay quiet here.
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
return () => handle.close()
|
|
174
|
+
}, [api])
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!open) return
|
|
178
|
+
void refreshList(false)
|
|
179
|
+
}, [open, refreshList])
|
|
180
|
+
|
|
181
|
+
const handleClickRow = useCallback(
|
|
182
|
+
async (n: Notification) => {
|
|
183
|
+
// Optimistic update: drop the unread count + tag the row read.
|
|
184
|
+
if (!n.readAt) {
|
|
185
|
+
setUnread((v) => Math.max(0, v - 1))
|
|
186
|
+
setItems((arr) =>
|
|
187
|
+
arr.map((row) => (row.id === n.id ? { ...row, readAt: new Date().toISOString() } : row)),
|
|
188
|
+
)
|
|
189
|
+
const out = await api.markRead(n.id)
|
|
190
|
+
if (!out.ok) {
|
|
191
|
+
onError?.(out.error)
|
|
192
|
+
// Roll back on failure.
|
|
193
|
+
setUnread((v) => v + 1)
|
|
194
|
+
setItems((arr) => arr.map((row) => (row.id === n.id ? { ...row, readAt: null } : row)))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
onSelect?.(n)
|
|
198
|
+
},
|
|
199
|
+
[api, onError, onSelect],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const handleMarkAll = useCallback(async () => {
|
|
203
|
+
// Snapshot ONLY the previously-unread IDs (and their original
|
|
204
|
+
// readAt values, which are null by definition). On rollback we
|
|
205
|
+
// restore those specific rows and leave anything that arrived
|
|
206
|
+
// via SSE while the request was in flight alone — otherwise a
|
|
207
|
+
// failed mark-all-read would drop fresh notifications.
|
|
208
|
+
const previouslyUnreadIds = items.filter((n) => n.readAt === null).map((n) => n.id)
|
|
209
|
+
if (previouslyUnreadIds.length === 0) return
|
|
210
|
+
const ids = new Set(previouslyUnreadIds)
|
|
211
|
+
const now = new Date().toISOString()
|
|
212
|
+
setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: n.readAt ?? now } : n)))
|
|
213
|
+
setUnread(0)
|
|
214
|
+
const out = await api.markAllRead()
|
|
215
|
+
if (!out.ok) {
|
|
216
|
+
onError?.(out.error)
|
|
217
|
+
// Functional rollback: only revert the rows we optimistically
|
|
218
|
+
// marked. Anything mid-flight (SSE arrivals, locally-added) is
|
|
219
|
+
// untouched.
|
|
220
|
+
setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: null } : n)))
|
|
221
|
+
await refreshUnread()
|
|
222
|
+
}
|
|
223
|
+
}, [api, items, onError, refreshUnread])
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className={`relative ${className ?? ''}`} data-testid="notification-bell">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="relative rounded p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
230
|
+
aria-label="Notifications"
|
|
231
|
+
aria-haspopup="menu"
|
|
232
|
+
aria-expanded={open}
|
|
233
|
+
onClick={() => setOpen((v) => !v)}
|
|
234
|
+
data-testid="bell-button"
|
|
235
|
+
>
|
|
236
|
+
<BellIcon />
|
|
237
|
+
{unread > 0 && (
|
|
238
|
+
<span
|
|
239
|
+
className="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold text-white"
|
|
240
|
+
data-testid="bell-badge"
|
|
241
|
+
>
|
|
242
|
+
{unread > 99 ? '99+' : unread}
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
{open && (
|
|
247
|
+
<div
|
|
248
|
+
role="menu"
|
|
249
|
+
className="absolute right-0 z-50 mt-1 w-80 rounded border border-gray-200 bg-white shadow-lg"
|
|
250
|
+
data-testid="bell-dropdown"
|
|
251
|
+
>
|
|
252
|
+
<header className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
|
|
253
|
+
<span className="text-sm font-semibold text-gray-900">Notifications</span>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
className="text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
|
257
|
+
onClick={handleMarkAll}
|
|
258
|
+
disabled={unread === 0}
|
|
259
|
+
data-testid="bell-mark-all"
|
|
260
|
+
>
|
|
261
|
+
Mark all read
|
|
262
|
+
</button>
|
|
263
|
+
</header>
|
|
264
|
+
<div className="max-h-80 overflow-y-auto" aria-busy={loading} data-testid="bell-list">
|
|
265
|
+
{loading ? (
|
|
266
|
+
<p className="px-3 py-2 text-xs text-gray-500">Loading…</p>
|
|
267
|
+
) : items.length === 0 ? (
|
|
268
|
+
<p className="px-3 py-2 text-xs text-gray-500">All caught up.</p>
|
|
269
|
+
) : (
|
|
270
|
+
<ul>
|
|
271
|
+
{items.map((n) => (
|
|
272
|
+
<li key={n.id}>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
role="menuitem"
|
|
276
|
+
className={`block w-full px-3 py-2 text-left text-xs hover:bg-gray-50 ${
|
|
277
|
+
n.readAt ? 'text-gray-500' : 'font-medium text-gray-900'
|
|
278
|
+
}`}
|
|
279
|
+
onClick={() => handleClickRow(n)}
|
|
280
|
+
data-testid={`bell-row-${n.id}`}
|
|
281
|
+
data-read={n.readAt ? 'true' : 'false'}
|
|
282
|
+
>
|
|
283
|
+
<div>{notificationSummary(n)}</div>
|
|
284
|
+
<div className="mt-0.5 text-[10px] text-gray-400">{n.createdAt}</div>
|
|
285
|
+
</button>
|
|
286
|
+
</li>
|
|
287
|
+
))}
|
|
288
|
+
</ul>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function BellIcon(): React.JSX.Element {
|
|
298
|
+
return (
|
|
299
|
+
<svg
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
width="18"
|
|
302
|
+
height="18"
|
|
303
|
+
viewBox="0 0 24 24"
|
|
304
|
+
fill="none"
|
|
305
|
+
stroke="currentColor"
|
|
306
|
+
strokeWidth="2"
|
|
307
|
+
strokeLinecap="round"
|
|
308
|
+
strokeLinejoin="round"
|
|
309
|
+
>
|
|
310
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
311
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
312
|
+
</svg>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
CollaborationStatus,
|
|
7
|
+
OfflinePersistenceStatus,
|
|
8
|
+
} from '../lib/collaboration-provider.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compact connection + offline-draft indicator. Rendered alongside the
|
|
12
|
+
* presence chips above the editor toolbar so editors get a single,
|
|
13
|
+
* always-visible signal:
|
|
14
|
+
*
|
|
15
|
+
* - **Saved & synced** — IndexedDB store is ready and the gateway
|
|
16
|
+
* socket is connected.
|
|
17
|
+
* - **Saved locally** — IndexedDB ready but the socket is disconnected
|
|
18
|
+
* (offline drafts in flight; will sync on reconnect).
|
|
19
|
+
* - **Syncing local copy…** — IndexedDB is replaying the last snapshot
|
|
20
|
+
* into the doc. The editor is still usable but undo / cursor sync
|
|
21
|
+
* may flicker briefly.
|
|
22
|
+
* - **Local drafts unsupported** — `indexedDB` is unavailable; the
|
|
23
|
+
* editor will rely on the gateway only. Refresh loses uncommitted
|
|
24
|
+
* edits.
|
|
25
|
+
* - **Local drafts failed** — IndexedDB threw on init (quota, private
|
|
26
|
+
* browsing). Same risk as "unsupported", but with a different cause.
|
|
27
|
+
*
|
|
28
|
+
* Keeping the component dumb and prop-driven keeps the unit tests
|
|
29
|
+
* trivial — the parent owns the status transitions.
|
|
30
|
+
*/
|
|
31
|
+
export interface OfflineStatusProps {
|
|
32
|
+
connection: CollaborationStatus
|
|
33
|
+
offline: OfflinePersistenceStatus
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function OfflineStatus({ connection, offline }: OfflineStatusProps): React.JSX.Element {
|
|
37
|
+
const { label, tone } = describeOfflineStatus(connection, offline)
|
|
38
|
+
const baseClass = 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium'
|
|
39
|
+
const toneClass = TONE_CLASSES[tone]
|
|
40
|
+
return (
|
|
41
|
+
<span className={`${baseClass} ${toneClass}`} data-testid="offline-status" data-tone={tone}>
|
|
42
|
+
<span
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className={`inline-block h-1.5 w-1.5 rounded-full ${DOT_CLASSES[tone]}`}
|
|
45
|
+
/>
|
|
46
|
+
{label}
|
|
47
|
+
</span>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type OfflineTone = 'good' | 'warn' | 'info' | 'bad'
|
|
52
|
+
|
|
53
|
+
const TONE_CLASSES: Record<OfflineTone, string> = {
|
|
54
|
+
good: 'bg-emerald-50 text-emerald-700',
|
|
55
|
+
warn: 'bg-amber-50 text-amber-800',
|
|
56
|
+
info: 'bg-blue-50 text-blue-700',
|
|
57
|
+
bad: 'bg-red-50 text-red-700',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DOT_CLASSES: Record<OfflineTone, string> = {
|
|
61
|
+
good: 'bg-emerald-500',
|
|
62
|
+
warn: 'bg-amber-500',
|
|
63
|
+
info: 'bg-blue-500',
|
|
64
|
+
bad: 'bg-red-500',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure helper that maps the (connection, offline) status pair to a
|
|
69
|
+
* single user-facing label + colour tone. Exported so the unit tests
|
|
70
|
+
* can exercise every combination without rendering React.
|
|
71
|
+
*/
|
|
72
|
+
export function describeOfflineStatus(
|
|
73
|
+
connection: CollaborationStatus,
|
|
74
|
+
offline: OfflinePersistenceStatus,
|
|
75
|
+
): { label: string; tone: OfflineTone } {
|
|
76
|
+
if (offline === 'error') return { label: 'Local drafts failed', tone: 'bad' }
|
|
77
|
+
if (offline === 'unsupported') {
|
|
78
|
+
// No local persistence; the WebSocket is the only safety net.
|
|
79
|
+
if (connection === 'connected') return { label: 'Synced', tone: 'good' }
|
|
80
|
+
if (connection === 'connecting') return { label: 'Connecting…', tone: 'info' }
|
|
81
|
+
return { label: 'Offline — drafts not persisted', tone: 'warn' }
|
|
82
|
+
}
|
|
83
|
+
if (offline === 'loading' || offline === 'pending') {
|
|
84
|
+
return { label: 'Restoring local copy…', tone: 'info' }
|
|
85
|
+
}
|
|
86
|
+
// offline === 'ready' from here on.
|
|
87
|
+
if (connection === 'connected') return { label: 'Saved & synced', tone: 'good' }
|
|
88
|
+
if (connection === 'connecting') return { label: 'Saved locally — reconnecting…', tone: 'warn' }
|
|
89
|
+
return { label: 'Saved locally — offline', tone: 'warn' }
|
|
90
|
+
}
|