@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.
Files changed (157) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +346 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +283 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +66 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +142 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/api.d.ts +8 -0
  99. package/dist/lib/api.d.ts.map +1 -1
  100. package/dist/lib/api.js +10 -0
  101. package/dist/lib/api.js.map +1 -1
  102. package/dist/lib/coauthor-client.d.ts +72 -0
  103. package/dist/lib/coauthor-client.d.ts.map +1 -0
  104. package/dist/lib/coauthor-client.js +98 -0
  105. package/dist/lib/coauthor-client.js.map +1 -0
  106. package/dist/lib/collaboration-provider.d.ts +142 -0
  107. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  108. package/dist/lib/collaboration-provider.js +168 -0
  109. package/dist/lib/collaboration-provider.js.map +1 -0
  110. package/dist/lib/comment-anchor.d.ts +86 -0
  111. package/dist/lib/comment-anchor.d.ts.map +1 -0
  112. package/dist/lib/comment-anchor.js +139 -0
  113. package/dist/lib/comment-anchor.js.map +1 -0
  114. package/dist/lib/comments-client.d.ts +106 -0
  115. package/dist/lib/comments-client.d.ts.map +1 -0
  116. package/dist/lib/comments-client.js +138 -0
  117. package/dist/lib/comments-client.js.map +1 -0
  118. package/dist/lib/notifications-client.d.ts +121 -0
  119. package/dist/lib/notifications-client.d.ts.map +1 -0
  120. package/dist/lib/notifications-client.js +173 -0
  121. package/dist/lib/notifications-client.js.map +1 -0
  122. package/dist/lib/slash-command.d.ts +39 -0
  123. package/dist/lib/slash-command.d.ts.map +1 -0
  124. package/dist/lib/slash-command.js +50 -0
  125. package/dist/lib/slash-command.js.map +1 -0
  126. package/package.json +12 -3
  127. package/src/__tests__/components/comment-mark.test.ts +99 -0
  128. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  129. package/src/__tests__/components/notification-bell.test.tsx +379 -0
  130. package/src/__tests__/components/offline-status.test.tsx +66 -0
  131. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  132. package/src/__tests__/lib/active-editor.test.ts +70 -0
  133. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  134. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  135. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  136. package/src/__tests__/lib/comments-client.test.ts +239 -0
  137. package/src/__tests__/lib/notifications-client.test.ts +339 -0
  138. package/src/__tests__/lib/slash-command.test.ts +146 -0
  139. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  140. package/src/components/CoauthorResultPopover.tsx +193 -0
  141. package/src/components/CommandPalette.tsx +397 -209
  142. package/src/components/CommentMark.ts +138 -0
  143. package/src/components/CommentSidePanel.tsx +521 -0
  144. package/src/components/LivePreview.tsx +117 -11
  145. package/src/components/NotificationBell.tsx +314 -0
  146. package/src/components/OfflineStatus.tsx +90 -0
  147. package/src/components/PresenceChips.tsx +150 -0
  148. package/src/components/TipTapEditor.tsx +310 -12
  149. package/src/index.ts +20 -0
  150. package/src/lib/active-editor.ts +39 -0
  151. package/src/lib/api.ts +11 -0
  152. package/src/lib/coauthor-client.ts +160 -0
  153. package/src/lib/collaboration-provider.ts +306 -0
  154. package/src/lib/comment-anchor.ts +179 -0
  155. package/src/lib/comments-client.ts +208 -0
  156. package/src/lib/notifications-client.ts +292 -0
  157. 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 { Monitor, Tablet, Smartphone, RefreshCw, ExternalLink, X } from 'lucide-react'
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
- if (!iframeRef.current?.contentWindow || !previewSrc) return
57
- let targetOrigin: string
58
- try {
59
- targetOrigin = new URL(previewSrc).origin
60
- } catch {
61
- targetOrigin = window.location.origin
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
- iframeRef.current.contentWindow.postMessage(
64
- { type: 'actuate-preview-update', data: values },
65
- targetOrigin,
109
+ setStreamStatus('connecting')
110
+ const source = new EventSource(
111
+ `${PREVIEW_STREAM_PATH}?token=${encodeURIComponent(previewToken)}`,
112
+ { withCredentials: true },
66
113
  )
67
- }, [values, previewSrc])
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
+ }