@actuate-media/cms-admin 0.12.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.
- 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 +200 -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 +155 -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 +45 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +90 -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/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 +68 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +85 -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 +213 -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 +195 -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 +242 -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/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 +146 -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,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
|
+
}
|