@strapi-community/plugin-io 5.0.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,12 @@ Add real-time capabilities to your Strapi application with WebSocket support. Au
39
39
  - **Role-Based Access Control** - Built-in permission checks for JWT and API tokens
40
40
  - **Multi-Client Support** - Handle 2500+ concurrent connections efficiently
41
41
 
42
+ ### Live Presence (NEW)
43
+ - **Real-Time Presence Awareness** - See who else is editing the same content
44
+ - **Typing Indicator** - See when someone is typing and in which field
45
+ - **Admin Panel Sidebar** - Live presence panel integrated into Content Manager
46
+ - **Session-Based Auth** - Secure admin authentication for Socket.IO connections
47
+
42
48
  ### Developer Experience
43
49
  - **Visual Admin Panel** - Configure everything through the Strapi admin interface
44
50
  - **TypeScript Support** - Full type definitions for IntelliSense
@@ -833,6 +839,39 @@ Navigate to **Settings > Socket.IO > Monitoring** for live statistics:
833
839
  - Send test events
834
840
  - Reset statistics
835
841
 
842
+ ### Live Presence Panel
843
+
844
+ When editing content in the Content Manager, a **Live Presence** panel appears in the sidebar showing:
845
+
846
+ - **Connection Status** - Live indicator showing real-time sync is active
847
+ - **Active Editors** - List of other users editing the same content
848
+ - **Typing Indicator** - Shows when someone is typing and in which field
849
+
850
+ **How It Works:**
851
+
852
+ 1. When you open a content entry, the panel connects via Socket.IO
853
+ 2. Other editors on the same entry appear in the panel
854
+ 3. Typing in any field broadcasts a typing indicator to others
855
+ 4. When you leave, others are notified
856
+
857
+ **Example Display:**
858
+
859
+ ```
860
+ +-----------------------------+
861
+ | Live Presence |
862
+ +-----------------------------+
863
+ | [*] Live |
864
+ | Real-time sync active |
865
+ +-----------------------------+
866
+ | ALSO EDITING (1) |
867
+ | +-------------------------+ |
868
+ | | SA Sarah Admin | |
869
+ | | Typing in: title | |
870
+ | | [Typing...] | |
871
+ | +-------------------------+ |
872
+ +-----------------------------+
873
+ ```
874
+
836
875
  ---
837
876
 
838
877
  ## Monitoring Service
@@ -1282,7 +1321,14 @@ Copyright (c) 2024 Strapi Community
1282
1321
 
1283
1322
  ## Changelog
1284
1323
 
1285
- ### v5.0.0 (Latest)
1324
+ ### v5.1.0 (Latest)
1325
+ - **Live Presence System** - Real-time presence awareness in Content Manager
1326
+ - **Typing Indicator** - See when others are typing and in which field
1327
+ - **Admin Panel Sidebar** - Live presence panel integrated into edit view
1328
+ - **Admin Session Authentication** - Secure session tokens for Socket.IO
1329
+ - **Admin JWT Strategy** - New authentication strategy for admin users
1330
+
1331
+ ### v5.0.0
1286
1332
  - Strapi v5 support
1287
1333
  - Package renamed to `@strapi-community/plugin-io`
1288
1334
  - Enhanced TypeScript support
@@ -0,0 +1,387 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useRef, useState, useEffect, useMemo, useCallback } from "react";
3
+ import { u as useIntl } from "./index-CEh8vkxY.mjs";
4
+ import { Flex } from "@strapi/design-system";
5
+ import { useFetchClient } from "@strapi/strapi/admin";
6
+ import styled, { css, keyframes } from "styled-components";
7
+ import { io } from "socket.io-client";
8
+ import { P as PLUGIN_ID } from "./index-CzvX8YTe.mjs";
9
+ const pulse = keyframes`
10
+ 0%, 100% {
11
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
12
+ transform: scale(1);
13
+ }
14
+ 50% {
15
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1);
16
+ transform: scale(1.1);
17
+ }
18
+ `;
19
+ const StatusCard = styled.div`
20
+ background: ${(props) => props.theme.colors.neutral0};
21
+ border: 1px solid ${({ $status, theme }) => $status === "connected" ? "rgba(34, 197, 94, 0.3)" : $status === "error" ? "rgba(239, 68, 68, 0.3)" : theme.colors.neutral200};
22
+ border-radius: 10px;
23
+ padding: 14px 16px;
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 12px;
27
+ `;
28
+ const StatusDot = styled.div`
29
+ width: 12px;
30
+ height: 12px;
31
+ border-radius: 50%;
32
+ flex-shrink: 0;
33
+ background: ${({ $status }) => $status === "connected" ? "#22c55e" : $status === "connecting" ? "#f59e0b" : $status === "error" ? "#ef4444" : "#94a3b8"};
34
+
35
+ ${({ $status }) => $status === "connected" && css`
36
+ animation: ${pulse} 2s ease-in-out infinite;
37
+ `}
38
+ `;
39
+ const StatusText = styled.div`
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: 2px;
43
+ `;
44
+ const StatusLabel = styled.span`
45
+ font-size: 14px;
46
+ font-weight: 600;
47
+ color: ${({ $status, theme }) => $status === "connected" ? theme.colors.success600 : $status === "connecting" ? theme.colors.warning600 : $status === "error" ? theme.colors.danger600 : theme.colors.neutral600};
48
+ `;
49
+ const StatusSubtext = styled.span`
50
+ font-size: 12px;
51
+ color: ${(props) => props.theme.colors.neutral500};
52
+ `;
53
+ const SectionTitle = styled.div`
54
+ font-size: 11px;
55
+ font-weight: 600;
56
+ color: ${(props) => props.theme.colors.neutral600};
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.5px;
59
+ margin-bottom: 10px;
60
+ `;
61
+ const EditorItem = styled.div`
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 12px;
65
+ padding: 12px 14px;
66
+ background: ${(props) => props.theme.colors.neutral0};
67
+ border-radius: 10px;
68
+ border: 1px solid ${(props) => props.theme.colors.neutral150};
69
+ transition: all 0.2s ease;
70
+
71
+ &:hover {
72
+ border-color: ${(props) => props.theme.colors.primary200};
73
+ box-shadow: 0 2px 8px rgba(73, 69, 255, 0.08);
74
+ transform: translateY(-1px);
75
+ }
76
+ `;
77
+ const EDITOR_COLORS = [
78
+ "linear-gradient(135deg, #4945ff 0%, #7b79ff 100%)",
79
+ "linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)",
80
+ "linear-gradient(135deg, #10b981 0%, #34d399 100%)",
81
+ "linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)",
82
+ "linear-gradient(135deg, #ef4444 0%, #f87171 100%)",
83
+ "linear-gradient(135deg, #ec4899 0%, #f472b6 100%)"
84
+ ];
85
+ const EditorAvatar = styled.div`
86
+ width: 36px;
87
+ height: 36px;
88
+ border-radius: 50%;
89
+ background: ${({ $color }) => $color || EDITOR_COLORS[0]};
90
+ color: white;
91
+ font-size: 12px;
92
+ font-weight: 700;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ flex-shrink: 0;
97
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
98
+ `;
99
+ const EditorInfo = styled.div`
100
+ flex: 1;
101
+ min-width: 0;
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 2px;
105
+ `;
106
+ const EditorName = styled.span`
107
+ font-size: 13px;
108
+ font-weight: 600;
109
+ color: ${(props) => props.theme.colors.neutral800};
110
+ white-space: nowrap;
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ `;
114
+ const EditorEmail = styled.span`
115
+ font-size: 11px;
116
+ color: ${(props) => props.theme.colors.neutral500};
117
+ white-space: nowrap;
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ `;
121
+ const EditingBadge = styled.span`
122
+ font-size: 10px;
123
+ font-weight: 600;
124
+ color: #166534;
125
+ background: #dcfce7;
126
+ padding: 4px 8px;
127
+ border-radius: 12px;
128
+ flex-shrink: 0;
129
+ `;
130
+ const TypingBadge = styled.span`
131
+ font-size: 10px;
132
+ font-weight: 600;
133
+ color: #92400e;
134
+ background: #fef3c7;
135
+ padding: 4px 8px;
136
+ border-radius: 12px;
137
+ flex-shrink: 0;
138
+ `;
139
+ const EmptyState = styled.div`
140
+ text-align: center;
141
+ padding: 16px;
142
+ background: ${(props) => props.theme.colors.neutral100};
143
+ border-radius: 10px;
144
+ border: 1px dashed ${(props) => props.theme.colors.neutral300};
145
+ `;
146
+ const EmptyText = styled.span`
147
+ font-size: 13px;
148
+ color: ${(props) => props.theme.colors.neutral500};
149
+ `;
150
+ const getEditorInitials = (user = {}) => {
151
+ const first = (user.firstname?.[0] || user.username?.[0] || user.email?.[0] || "?").toUpperCase();
152
+ const last = (user.lastname?.[0] || "").toUpperCase();
153
+ return `${first}${last}`.trim();
154
+ };
155
+ const getEditorName = (user = {}) => {
156
+ if (user.firstname) {
157
+ return `${user.firstname} ${user.lastname || ""}`.trim();
158
+ }
159
+ return user.username || user.email || "Unknown";
160
+ };
161
+ const LivePresencePanel = ({ documentId, model, document }) => {
162
+ const { formatMessage } = useIntl();
163
+ const { post } = useFetchClient();
164
+ const t = (id, defaultMessage, values) => formatMessage({ id: `${PLUGIN_ID}.${id}`, defaultMessage }, values);
165
+ const socketRef = useRef(null);
166
+ const [sessionData, setSessionData] = useState(null);
167
+ const [presenceState, setPresenceState] = useState({
168
+ status: "initializing",
169
+ editors: [],
170
+ typingUsers: [],
171
+ error: null
172
+ });
173
+ const uid = model?.uid || model;
174
+ useEffect(() => {
175
+ if (!uid || !documentId) {
176
+ setPresenceState((prev) => ({ ...prev, status: "disconnected", error: "No content" }));
177
+ return;
178
+ }
179
+ let cancelled = false;
180
+ const getSession = async () => {
181
+ try {
182
+ setPresenceState((prev) => ({ ...prev, status: "requesting" }));
183
+ const { data } = await post(`/${PLUGIN_ID}/presence/session`, {});
184
+ if (cancelled) return;
185
+ if (!data || !data.token) {
186
+ throw new Error("Invalid session response");
187
+ }
188
+ console.log(`[${PLUGIN_ID}] Presence session obtained for:`, data.user?.email);
189
+ setSessionData(data);
190
+ setPresenceState((prev) => ({ ...prev, status: "connecting" }));
191
+ } catch (error2) {
192
+ if (cancelled) return;
193
+ console.error(`[${PLUGIN_ID}] Failed to get presence session:`, error2);
194
+ setPresenceState((prev) => ({
195
+ ...prev,
196
+ status: "error",
197
+ error: error2.message || "Failed to get session"
198
+ }));
199
+ }
200
+ };
201
+ getSession();
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [uid, documentId, post]);
206
+ useEffect(() => {
207
+ if (!sessionData?.token || !uid || !documentId) {
208
+ return;
209
+ }
210
+ const socketUrl = sessionData.wsUrl || `${window.location.protocol}//${window.location.host}`;
211
+ const socket = io(socketUrl, {
212
+ path: sessionData.wsPath || "/socket.io",
213
+ transports: ["websocket", "polling"],
214
+ auth: {
215
+ token: sessionData.token,
216
+ strategy: "admin-jwt",
217
+ isAdmin: true
218
+ },
219
+ reconnection: true,
220
+ reconnectionAttempts: 3
221
+ });
222
+ socketRef.current = socket;
223
+ let lastTypingEmit = 0;
224
+ const TYPING_THROTTLE = 2e3;
225
+ const getFieldName = (element) => {
226
+ const name = element.name || element.id || "";
227
+ const label = element.closest("label") || document.querySelector(`label[for="${element.id}"]`);
228
+ if (label) {
229
+ return label.textContent?.trim() || name;
230
+ }
231
+ const fieldWrapper = element.closest('[class*="Field"]');
232
+ if (fieldWrapper) {
233
+ const labelEl = fieldWrapper.querySelector('label, [class*="Label"]');
234
+ if (labelEl) {
235
+ return labelEl.textContent?.trim() || name;
236
+ }
237
+ }
238
+ return name || "unknown field";
239
+ };
240
+ const handleInput = (event) => {
241
+ const target = event.target;
242
+ if (!["INPUT", "TEXTAREA"].includes(target.tagName)) return;
243
+ const isInContentManager = target.closest('[class*="ContentLayout"]') || target.closest("main");
244
+ if (!isInContentManager) return;
245
+ const now = Date.now();
246
+ if (now - lastTypingEmit < TYPING_THROTTLE) return;
247
+ lastTypingEmit = now;
248
+ const fieldName = getFieldName(target);
249
+ if (socket.connected) {
250
+ socket.emit("presence:typing", { uid, documentId, fieldName });
251
+ console.log(`[${PLUGIN_ID}] Typing in field: ${fieldName}`);
252
+ }
253
+ };
254
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
255
+ document.addEventListener("input", handleInput, true);
256
+ }
257
+ socket.on("connect", () => {
258
+ console.log(`[${PLUGIN_ID}] Presence socket connected`);
259
+ setPresenceState((prev) => ({ ...prev, status: "connected", error: null }));
260
+ socket.emit("presence:join", { uid, documentId }, (response) => {
261
+ if (response?.success) {
262
+ setPresenceState((prev) => ({
263
+ ...prev,
264
+ editors: (response.editors || []).map((e) => ({
265
+ ...e,
266
+ isCurrentUser: e.socketId === socket.id
267
+ }))
268
+ }));
269
+ }
270
+ });
271
+ });
272
+ socket.on("disconnect", () => {
273
+ setPresenceState((prev) => ({ ...prev, status: "disconnected" }));
274
+ });
275
+ socket.on("connect_error", (err) => {
276
+ console.warn(`[${PLUGIN_ID}] Presence socket error:`, err.message);
277
+ setPresenceState((prev) => ({ ...prev, status: "error", error: err.message }));
278
+ });
279
+ socket.on("presence:update", (data) => {
280
+ if (data.uid === uid && data.documentId === documentId) {
281
+ setPresenceState((prev) => ({
282
+ ...prev,
283
+ editors: (data.editors || []).map((e) => ({
284
+ ...e,
285
+ isCurrentUser: e.socketId === socket.id
286
+ }))
287
+ }));
288
+ }
289
+ });
290
+ socket.on("presence:typing", (data) => {
291
+ if (data.uid === uid && data.documentId === documentId) {
292
+ setPresenceState((prev) => {
293
+ const newTyping = [...prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)];
294
+ newTyping.push({ user: data.user, fieldName: data.fieldName, timestamp: Date.now() });
295
+ return { ...prev, typingUsers: newTyping };
296
+ });
297
+ setTimeout(() => {
298
+ setPresenceState((prev) => ({
299
+ ...prev,
300
+ typingUsers: prev.typingUsers.filter((t2) => t2.user?.id !== data.user?.id)
301
+ }));
302
+ }, 3e3);
303
+ }
304
+ });
305
+ const heartbeat = setInterval(() => {
306
+ if (socket.connected) {
307
+ socket.emit("presence:heartbeat");
308
+ }
309
+ }, 3e4);
310
+ return () => {
311
+ clearInterval(heartbeat);
312
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
313
+ document.removeEventListener("input", handleInput, true);
314
+ }
315
+ if (socket.connected) {
316
+ socket.emit("presence:leave", { uid, documentId });
317
+ }
318
+ socket.disconnect();
319
+ socketRef.current = null;
320
+ };
321
+ }, [sessionData, uid, documentId]);
322
+ const { status, editors, typingUsers, error } = presenceState;
323
+ const otherEditors = useMemo(() => {
324
+ return editors.filter((e) => !e.isCurrentUser);
325
+ }, [editors]);
326
+ const getUserTypingInfo = useCallback((userId) => {
327
+ const typing = typingUsers.find((t2) => t2.user?.id === userId);
328
+ return typing || null;
329
+ }, [typingUsers]);
330
+ useCallback((userId) => {
331
+ return typingUsers.some((t2) => t2.user?.id === userId);
332
+ }, [typingUsers]);
333
+ const statusLabel = useMemo(() => {
334
+ switch (status) {
335
+ case "connected":
336
+ return t("presence.live", "Live");
337
+ case "connecting":
338
+ return t("presence.connecting", "Connecting...");
339
+ case "requesting":
340
+ return t("presence.requesting", "Authenticating...");
341
+ case "initializing":
342
+ return t("presence.initializing", "Initializing...");
343
+ case "error":
344
+ return t("presence.error", "Connection Error");
345
+ case "disconnected":
346
+ return t("presence.disconnected", "Disconnected");
347
+ default:
348
+ return t("presence.offline", "Offline");
349
+ }
350
+ }, [status, t]);
351
+ const isConnected = status === "connected";
352
+ console.log(`[${PLUGIN_ID}] LivePresencePanel render:`, { uid, documentId, status, editors: otherEditors.length });
353
+ return {
354
+ title: t("presence.title", "Live Presence"),
355
+ content: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 4, alignItems: "stretch", style: { width: "100%" }, children: [
356
+ /* @__PURE__ */ jsxs(StatusCard, { $status: status, children: [
357
+ /* @__PURE__ */ jsx(StatusDot, { $status: status }),
358
+ /* @__PURE__ */ jsxs(StatusText, { children: [
359
+ /* @__PURE__ */ jsx(StatusLabel, { $status: status, children: statusLabel }),
360
+ /* @__PURE__ */ jsx(StatusSubtext, { children: isConnected ? t("presence.realtimeActive", "Real-time sync active") : error || t("presence.establishing", "Establishing connection...") })
361
+ ] })
362
+ ] }),
363
+ isConnected && otherEditors.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
364
+ /* @__PURE__ */ jsx(SectionTitle, { children: t("presence.activeEditors", "Also Editing ({count})", { count: otherEditors.length }) }),
365
+ /* @__PURE__ */ jsx(Flex, { direction: "column", gap: 2, alignItems: "stretch", children: otherEditors.map((editor, idx) => {
366
+ const user = editor.user || {};
367
+ const typingInfo = getUserTypingInfo(user.id);
368
+ return /* @__PURE__ */ jsxs(EditorItem, { children: [
369
+ /* @__PURE__ */ jsx(EditorAvatar, { $color: EDITOR_COLORS[idx % EDITOR_COLORS.length], children: getEditorInitials(user) }),
370
+ /* @__PURE__ */ jsxs(EditorInfo, { children: [
371
+ /* @__PURE__ */ jsx(EditorName, { children: getEditorName(user) }),
372
+ typingInfo?.fieldName ? /* @__PURE__ */ jsxs(EditorEmail, { children: [
373
+ "Typing in: ",
374
+ typingInfo.fieldName
375
+ ] }) : user.email && user.firstname ? /* @__PURE__ */ jsx(EditorEmail, { children: user.email }) : null
376
+ ] }),
377
+ typingInfo ? /* @__PURE__ */ jsx(TypingBadge, { children: t("presence.typing", "Typing...") }) : /* @__PURE__ */ jsx(EditingBadge, { children: t("presence.editing", "Editing") })
378
+ ] }, editor.socketId || idx);
379
+ }) })
380
+ ] }),
381
+ isConnected && otherEditors.length === 0 && /* @__PURE__ */ jsx(EmptyState, { children: /* @__PURE__ */ jsx(EmptyText, { children: t("presence.workingAlone", "You are the only editor") }) })
382
+ ] })
383
+ };
384
+ };
385
+ export {
386
+ LivePresencePanel as default
387
+ };