@actuate-media/cms-admin 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  3. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/comment-mark.test.js +89 -0
  5. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  7. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  9. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  11. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/notification-bell.test.js +200 -0
  13. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  15. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  16. package/dist/__tests__/components/offline-status.test.js +56 -0
  17. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  21. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  22. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  23. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  24. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  25. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  26. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  27. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  28. package/dist/__tests__/lib/active-editor.test.js +57 -0
  29. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  30. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  31. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  32. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  33. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  34. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  35. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  36. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  37. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  38. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  39. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  40. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  41. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  42. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  43. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  44. package/dist/__tests__/lib/comments-client.test.js +202 -0
  45. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  46. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  47. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  48. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  49. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  50. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  51. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  52. package/dist/__tests__/lib/slash-command.test.js +123 -0
  53. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  54. package/dist/actuate-admin.css +1 -1
  55. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  56. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  57. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  58. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  59. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  60. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  61. package/dist/components/CoauthorResultPopover.js +42 -0
  62. package/dist/components/CoauthorResultPopover.js.map +1 -0
  63. package/dist/components/CommandPalette.d.ts +1 -1
  64. package/dist/components/CommandPalette.d.ts.map +1 -1
  65. package/dist/components/CommandPalette.js +138 -40
  66. package/dist/components/CommandPalette.js.map +1 -1
  67. package/dist/components/CommentMark.d.ts +54 -0
  68. package/dist/components/CommentMark.d.ts.map +1 -0
  69. package/dist/components/CommentMark.js +104 -0
  70. package/dist/components/CommentMark.js.map +1 -0
  71. package/dist/components/CommentSidePanel.d.ts +86 -0
  72. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  73. package/dist/components/CommentSidePanel.js +165 -0
  74. package/dist/components/CommentSidePanel.js.map +1 -0
  75. package/dist/components/LivePreview.d.ts.map +1 -1
  76. package/dist/components/LivePreview.js +80 -16
  77. package/dist/components/LivePreview.js.map +1 -1
  78. package/dist/components/NotificationBell.d.ts +45 -0
  79. package/dist/components/NotificationBell.d.ts.map +1 -0
  80. package/dist/components/NotificationBell.js +90 -0
  81. package/dist/components/NotificationBell.js.map +1 -0
  82. package/dist/components/OfflineStatus.d.ts +39 -0
  83. package/dist/components/OfflineStatus.d.ts.map +1 -0
  84. package/dist/components/OfflineStatus.js +47 -0
  85. package/dist/components/OfflineStatus.js.map +1 -0
  86. package/dist/components/PresenceChips.d.ts +31 -0
  87. package/dist/components/PresenceChips.d.ts.map +1 -0
  88. package/dist/components/PresenceChips.js +58 -0
  89. package/dist/components/PresenceChips.js.map +1 -0
  90. package/dist/components/TipTapEditor.d.ts +61 -1
  91. package/dist/components/TipTapEditor.d.ts.map +1 -1
  92. package/dist/components/TipTapEditor.js +211 -8
  93. package/dist/components/TipTapEditor.js.map +1 -1
  94. package/dist/components/ui/Badge.d.ts +1 -1
  95. package/dist/fields/ComponentBlockField.d.ts +25 -0
  96. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  97. package/dist/fields/ComponentBlockField.js +74 -0
  98. package/dist/fields/ComponentBlockField.js.map +1 -0
  99. package/dist/fields/FieldRenderer.d.ts +3 -0
  100. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  101. package/dist/fields/FieldRenderer.js +3 -1
  102. package/dist/fields/FieldRenderer.js.map +1 -1
  103. package/dist/fields/PropInput.d.ts +14 -0
  104. package/dist/fields/PropInput.d.ts.map +1 -0
  105. package/dist/fields/PropInput.js +163 -0
  106. package/dist/fields/PropInput.js.map +1 -0
  107. package/dist/fields/component-block-helpers.d.ts +96 -0
  108. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  109. package/dist/fields/component-block-helpers.js +323 -0
  110. package/dist/fields/component-block-helpers.js.map +1 -0
  111. package/dist/fields/index.d.ts +4 -0
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +2 -0
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/index.d.ts +10 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +5 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/lib/active-editor.d.ts +6 -0
  120. package/dist/lib/active-editor.d.ts.map +1 -0
  121. package/dist/lib/active-editor.js +35 -0
  122. package/dist/lib/active-editor.js.map +1 -0
  123. package/dist/lib/coauthor-client.d.ts +72 -0
  124. package/dist/lib/coauthor-client.d.ts.map +1 -0
  125. package/dist/lib/coauthor-client.js +98 -0
  126. package/dist/lib/coauthor-client.js.map +1 -0
  127. package/dist/lib/collaboration-provider.d.ts +142 -0
  128. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  129. package/dist/lib/collaboration-provider.js +168 -0
  130. package/dist/lib/collaboration-provider.js.map +1 -0
  131. package/dist/lib/comment-anchor.d.ts +86 -0
  132. package/dist/lib/comment-anchor.d.ts.map +1 -0
  133. package/dist/lib/comment-anchor.js +139 -0
  134. package/dist/lib/comment-anchor.js.map +1 -0
  135. package/dist/lib/comments-client.d.ts +106 -0
  136. package/dist/lib/comments-client.d.ts.map +1 -0
  137. package/dist/lib/comments-client.js +138 -0
  138. package/dist/lib/comments-client.js.map +1 -0
  139. package/dist/lib/notifications-client.d.ts +68 -0
  140. package/dist/lib/notifications-client.d.ts.map +1 -0
  141. package/dist/lib/notifications-client.js +85 -0
  142. package/dist/lib/notifications-client.js.map +1 -0
  143. package/dist/lib/slash-command.d.ts +39 -0
  144. package/dist/lib/slash-command.d.ts.map +1 -0
  145. package/dist/lib/slash-command.js +50 -0
  146. package/dist/lib/slash-command.js.map +1 -0
  147. package/package.json +20 -4
  148. package/src/__tests__/components/comment-mark.test.ts +99 -0
  149. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  150. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  151. package/src/__tests__/components/offline-status.test.tsx +66 -0
  152. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  153. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  154. package/src/__tests__/lib/active-editor.test.ts +70 -0
  155. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  156. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  157. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  158. package/src/__tests__/lib/comments-client.test.ts +239 -0
  159. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  160. package/src/__tests__/lib/slash-command.test.ts +146 -0
  161. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  162. package/src/components/CoauthorResultPopover.tsx +193 -0
  163. package/src/components/CommandPalette.tsx +397 -209
  164. package/src/components/CommentMark.ts +138 -0
  165. package/src/components/CommentSidePanel.tsx +521 -0
  166. package/src/components/LivePreview.tsx +117 -11
  167. package/src/components/NotificationBell.tsx +242 -0
  168. package/src/components/OfflineStatus.tsx +90 -0
  169. package/src/components/PresenceChips.tsx +150 -0
  170. package/src/components/TipTapEditor.tsx +310 -12
  171. package/src/fields/ComponentBlockField.tsx +179 -0
  172. package/src/fields/FieldRenderer.tsx +8 -0
  173. package/src/fields/PropInput.tsx +552 -0
  174. package/src/fields/component-block-helpers.ts +341 -0
  175. package/src/fields/index.ts +4 -0
  176. package/src/index.ts +27 -0
  177. package/src/lib/active-editor.ts +39 -0
  178. package/src/lib/coauthor-client.ts +160 -0
  179. package/src/lib/collaboration-provider.ts +306 -0
  180. package/src/lib/comment-anchor.ts +179 -0
  181. package/src/lib/comments-client.ts +208 -0
  182. package/src/lib/notifications-client.ts +146 -0
  183. 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,242 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ import {
6
+ type Notification,
7
+ type NotificationOutcome,
8
+ listNotifications as defaultListNotifications,
9
+ markAllNotificationsRead as defaultMarkAllRead,
10
+ markNotificationRead as defaultMarkRead,
11
+ notificationSummary,
12
+ unreadCount as defaultUnreadCount,
13
+ } from '../lib/notifications-client.js'
14
+
15
+ /**
16
+ * Top-bar notification bell + dropdown panel. Slice 6 of Phase 3
17
+ * surfaces every collaboration event that affects the current user —
18
+ * comment replies, resolves, and mentions — without forcing the
19
+ * editor to round-trip through the comments side panel.
20
+ *
21
+ * Design choices:
22
+ *
23
+ * - **Polling, not SSE.** Phase 3 already ships an SSE stream for live
24
+ * previews (Phase 2 / `/preview/stream`), but extending it to
25
+ * per-user notifications adds a non-trivial fan-out path and a fresh
26
+ * security surface (privacy-leak risk on misconfigured streams). The
27
+ * bell polls every `pollIntervalMs` (default 30s) and on dropdown
28
+ * open. A push-based extension is queued as a Phase 4 follow-up.
29
+ * - **Optimistic read state.** When the user clicks a notification we
30
+ * mark it read locally before the server confirms — so the bell
31
+ * counter feels instant. Server failures roll the state back via
32
+ * the `onError` toast.
33
+ * - **API injection.** The dependency-injection seam matches the rest
34
+ * of the admin (`CommentSidePanel.api`) so unit tests can substitute
35
+ * in-memory fakes.
36
+ */
37
+ export interface NotificationsApi {
38
+ list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
39
+ unread: () => Promise<NotificationOutcome<number>>
40
+ markRead: (id: string) => Promise<NotificationOutcome<Notification>>
41
+ markAllRead: () => Promise<NotificationOutcome<number>>
42
+ }
43
+
44
+ const defaultApi: NotificationsApi = {
45
+ list: (opts) => defaultListNotifications(opts),
46
+ unread: () => defaultUnreadCount(),
47
+ markRead: (id) => defaultMarkRead(id),
48
+ markAllRead: () => defaultMarkAllRead(),
49
+ }
50
+
51
+ export interface NotificationBellProps {
52
+ /** Polling cadence in milliseconds. Default 30,000. Pass 0 to disable. */
53
+ pollIntervalMs?: number
54
+ /** Optional click hook — called with the notification when a row is clicked. */
55
+ onSelect?: (notification: Notification) => void
56
+ /** Error sink — bubble server failures to a toast / banner. */
57
+ onError?: (message: string) => void
58
+ /** Override the API client (tests). */
59
+ api?: NotificationsApi
60
+ className?: string
61
+ }
62
+
63
+ export function NotificationBell({
64
+ pollIntervalMs = 30_000,
65
+ onSelect,
66
+ onError,
67
+ api = defaultApi,
68
+ className,
69
+ }: NotificationBellProps): React.JSX.Element {
70
+ const [open, setOpen] = useState(false)
71
+ const [unread, setUnread] = useState(0)
72
+ const [items, setItems] = useState<Notification[]>([])
73
+ const [loading, setLoading] = useState(false)
74
+ const mountedRef = useRef(true)
75
+ useEffect(() => {
76
+ return () => {
77
+ mountedRef.current = false
78
+ }
79
+ }, [])
80
+
81
+ const refreshUnread = useCallback(async () => {
82
+ const out = await api.unread()
83
+ if (!mountedRef.current) return
84
+ if (!out.ok) {
85
+ onError?.(out.error)
86
+ return
87
+ }
88
+ setUnread(out.result)
89
+ }, [api, onError])
90
+
91
+ const refreshList = useCallback(
92
+ async (includeRead = false) => {
93
+ setLoading(true)
94
+ const out = await api.list({ includeRead })
95
+ if (!mountedRef.current) return
96
+ if (!out.ok) {
97
+ onError?.(out.error)
98
+ setItems([])
99
+ } else {
100
+ setItems(out.result)
101
+ }
102
+ setLoading(false)
103
+ },
104
+ [api, onError],
105
+ )
106
+
107
+ useEffect(() => {
108
+ void refreshUnread()
109
+ if (!pollIntervalMs) return
110
+ const id = setInterval(() => void refreshUnread(), pollIntervalMs)
111
+ return () => clearInterval(id)
112
+ }, [refreshUnread, pollIntervalMs])
113
+
114
+ useEffect(() => {
115
+ if (!open) return
116
+ void refreshList(false)
117
+ }, [open, refreshList])
118
+
119
+ const handleClickRow = useCallback(
120
+ async (n: Notification) => {
121
+ // Optimistic update: drop the unread count + tag the row read.
122
+ if (!n.readAt) {
123
+ setUnread((v) => Math.max(0, v - 1))
124
+ setItems((arr) =>
125
+ arr.map((row) => (row.id === n.id ? { ...row, readAt: new Date().toISOString() } : row)),
126
+ )
127
+ const out = await api.markRead(n.id)
128
+ if (!out.ok) {
129
+ onError?.(out.error)
130
+ // Roll back on failure.
131
+ setUnread((v) => v + 1)
132
+ setItems((arr) => arr.map((row) => (row.id === n.id ? { ...row, readAt: null } : row)))
133
+ }
134
+ }
135
+ onSelect?.(n)
136
+ },
137
+ [api, onError, onSelect],
138
+ )
139
+
140
+ const handleMarkAll = useCallback(async () => {
141
+ const previous = items.slice()
142
+ const now = new Date().toISOString()
143
+ setItems(items.map((n) => ({ ...n, readAt: n.readAt ?? now })))
144
+ setUnread(0)
145
+ const out = await api.markAllRead()
146
+ if (!out.ok) {
147
+ onError?.(out.error)
148
+ setItems(previous)
149
+ await refreshUnread()
150
+ }
151
+ }, [api, items, onError, refreshUnread])
152
+
153
+ return (
154
+ <div className={`relative ${className ?? ''}`} data-testid="notification-bell">
155
+ <button
156
+ type="button"
157
+ className="relative rounded p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
158
+ aria-label="Notifications"
159
+ aria-haspopup="menu"
160
+ aria-expanded={open}
161
+ onClick={() => setOpen((v) => !v)}
162
+ data-testid="bell-button"
163
+ >
164
+ <BellIcon />
165
+ {unread > 0 && (
166
+ <span
167
+ 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"
168
+ data-testid="bell-badge"
169
+ >
170
+ {unread > 99 ? '99+' : unread}
171
+ </span>
172
+ )}
173
+ </button>
174
+ {open && (
175
+ <div
176
+ role="menu"
177
+ className="absolute right-0 z-50 mt-1 w-80 rounded border border-gray-200 bg-white shadow-lg"
178
+ data-testid="bell-dropdown"
179
+ >
180
+ <header className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
181
+ <span className="text-sm font-semibold text-gray-900">Notifications</span>
182
+ <button
183
+ type="button"
184
+ className="text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
185
+ onClick={handleMarkAll}
186
+ disabled={unread === 0}
187
+ data-testid="bell-mark-all"
188
+ >
189
+ Mark all read
190
+ </button>
191
+ </header>
192
+ <div className="max-h-80 overflow-y-auto" aria-busy={loading} data-testid="bell-list">
193
+ {loading ? (
194
+ <p className="px-3 py-2 text-xs text-gray-500">Loading…</p>
195
+ ) : items.length === 0 ? (
196
+ <p className="px-3 py-2 text-xs text-gray-500">All caught up.</p>
197
+ ) : (
198
+ <ul>
199
+ {items.map((n) => (
200
+ <li key={n.id}>
201
+ <button
202
+ type="button"
203
+ role="menuitem"
204
+ className={`block w-full px-3 py-2 text-left text-xs hover:bg-gray-50 ${
205
+ n.readAt ? 'text-gray-500' : 'font-medium text-gray-900'
206
+ }`}
207
+ onClick={() => handleClickRow(n)}
208
+ data-testid={`bell-row-${n.id}`}
209
+ data-read={n.readAt ? 'true' : 'false'}
210
+ >
211
+ <div>{notificationSummary(n)}</div>
212
+ <div className="mt-0.5 text-[10px] text-gray-400">{n.createdAt}</div>
213
+ </button>
214
+ </li>
215
+ ))}
216
+ </ul>
217
+ )}
218
+ </div>
219
+ </div>
220
+ )}
221
+ </div>
222
+ )
223
+ }
224
+
225
+ function BellIcon(): React.JSX.Element {
226
+ return (
227
+ <svg
228
+ aria-hidden="true"
229
+ width="18"
230
+ height="18"
231
+ viewBox="0 0 24 24"
232
+ fill="none"
233
+ stroke="currentColor"
234
+ strokeWidth="2"
235
+ strokeLinecap="round"
236
+ strokeLinejoin="round"
237
+ >
238
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
239
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
240
+ </svg>
241
+ )
242
+ }
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useState } from 'react'
4
+ import { Awareness } from 'y-protocols/awareness'
5
+
6
+ import {
7
+ summarisePresence,
8
+ type CollaborationStatus,
9
+ type PresenceSnapshot,
10
+ } from '../lib/collaboration-provider.js'
11
+
12
+ export interface PresenceChipsProps {
13
+ /** Awareness instance returned from `createCollaborationProvider`. */
14
+ awareness: Awareness
15
+ /**
16
+ * Optional connection status. When provided the strip renders a small
17
+ * status pill (Connected / Connecting / Disconnected) on the left side.
18
+ */
19
+ status?: CollaborationStatus
20
+ /**
21
+ * Hide the local user from the strip. Defaults to `true` — the local user
22
+ * already knows they're editing.
23
+ */
24
+ hideSelf?: boolean
25
+ /** Max chips to show before collapsing to "+N". Defaults to 5. */
26
+ maxVisible?: number
27
+ className?: string
28
+ }
29
+
30
+ /**
31
+ * Small avatar / initials strip showing who else is currently editing the
32
+ * document. Subscribes to the supplied `Awareness` instance for live
33
+ * updates.
34
+ *
35
+ * The component is intentionally stateless about transport — it only reads
36
+ * from Awareness and `status`. Wiring is the caller's job.
37
+ */
38
+ export function PresenceChips({
39
+ awareness,
40
+ status,
41
+ hideSelf = true,
42
+ maxVisible = 5,
43
+ className,
44
+ }: PresenceChipsProps) {
45
+ const [snapshot, setSnapshot] = useState<PresenceSnapshot>(() => summarisePresence(awareness))
46
+
47
+ useEffect(() => {
48
+ const update = () => {
49
+ setSnapshot(summarisePresence(awareness))
50
+ }
51
+ update()
52
+ awareness.on('update', update)
53
+ awareness.on('change', update)
54
+ return () => {
55
+ awareness.off('update', update)
56
+ awareness.off('change', update)
57
+ }
58
+ }, [awareness])
59
+
60
+ const visible = hideSelf
61
+ ? snapshot.users.filter((u) => u.clientId !== snapshot.selfClientId)
62
+ : snapshot.users
63
+ const overflow = Math.max(0, visible.length - maxVisible)
64
+ const shown = visible.slice(0, maxVisible)
65
+
66
+ return (
67
+ <div
68
+ className={`inline-flex items-center gap-1.5 text-xs ${className ?? ''}`}
69
+ data-testid="presence-chips"
70
+ >
71
+ {status && <StatusPill status={status} />}
72
+ {shown.length === 0 && status !== 'connecting' && (
73
+ <span className="text-gray-500" data-testid="presence-empty">
74
+ You're the only one here
75
+ </span>
76
+ )}
77
+ {shown.map(({ clientId, user }) => (
78
+ <Chip key={clientId} name={user.name} color={user.color} avatarUrl={user.avatarUrl} />
79
+ ))}
80
+ {overflow > 0 && (
81
+ <span
82
+ className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-700"
83
+ data-testid="presence-overflow"
84
+ >
85
+ +{overflow}
86
+ </span>
87
+ )}
88
+ </div>
89
+ )
90
+ }
91
+
92
+ function StatusPill({ status }: { status: CollaborationStatus }) {
93
+ const palette: Record<CollaborationStatus, { dot: string; label: string; text: string }> = {
94
+ connected: { dot: 'bg-emerald-500', label: 'Live', text: 'text-emerald-700' },
95
+ connecting: { dot: 'bg-amber-400 animate-pulse', label: 'Connecting', text: 'text-amber-700' },
96
+ disconnected: { dot: 'bg-gray-300', label: 'Offline', text: 'text-gray-500' },
97
+ }
98
+ const cfg = palette[status]
99
+ return (
100
+ <span
101
+ className={`inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 ${cfg.text}`}
102
+ data-testid={`presence-status-${status}`}
103
+ >
104
+ <span className={`h-1.5 w-1.5 rounded-full ${cfg.dot}`} />
105
+ {cfg.label}
106
+ </span>
107
+ )
108
+ }
109
+
110
+ function Chip({ name, color, avatarUrl }: { name: string; color: string; avatarUrl?: string }) {
111
+ const initials = nameToInitials(name)
112
+ return (
113
+ <span
114
+ className="inline-flex items-center gap-1 rounded-full bg-white px-1.5 py-0.5 ring-1 ring-gray-200"
115
+ title={name}
116
+ data-testid="presence-chip"
117
+ >
118
+ {avatarUrl ? (
119
+ <img
120
+ src={avatarUrl}
121
+ alt=""
122
+ className="h-4 w-4 rounded-full object-cover"
123
+ style={{ boxShadow: `0 0 0 1.5px ${color}` }}
124
+ />
125
+ ) : (
126
+ <span
127
+ aria-hidden="true"
128
+ className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-semibold text-white"
129
+ style={{ backgroundColor: color }}
130
+ >
131
+ {initials}
132
+ </span>
133
+ )}
134
+ <span className="max-w-[80px] truncate text-gray-700">{name}</span>
135
+ </span>
136
+ )
137
+ }
138
+
139
+ /** Convert "Alice Cooper" → "AC", single names → "A", empty → "?". */
140
+ export function nameToInitials(name: string): string {
141
+ const cleaned = name.trim()
142
+ if (!cleaned) return '?'
143
+ const parts = cleaned.split(/\s+/).slice(0, 2)
144
+ return (
145
+ parts
146
+ .map((p) => (p[0] ?? '').toUpperCase())
147
+ .filter(Boolean)
148
+ .join('') || '?'
149
+ )
150
+ }