@thinhnd028/strapi-plugin-presence 0.0.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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Strapi 5 Real-Time Presence Plugin
2
+
3
+ A premium real-time presence plugin for Strapi 5 that allows content creators to see who else is currently viewing or editing the same entry in the Content Manager.
4
+
5
+ ## Features
6
+
7
+ - **Real-Time Tracking**: Instant updates when users join or leave a content entry.
8
+ - **Premium UI**: Modern glassmorphism design that integrates seamlessly with Strapi 5's admin panel.
9
+ - **Unique User Identity**:
10
+ - Automatically identifies users via Strapi 5's cookie-based authentication.
11
+ - Generates unique initials and persistent colors for each user.
12
+ - Interactive tooltips showing full usernames.
13
+ - **Deduplication**: Handles multiple open tabs from the same user by showing only one avatar.
14
+ - **Optimized Performance**: Uses Socket.io for low-latency communication with fallback support.
15
+
16
+ ## Technical Details
17
+
18
+ - **Frontend**: React-based component injected into the `right-links` zone of the Content Manager.
19
+ - **Backend**: Socket.io server integrated into the Strapi lifecycle.
20
+ - **Authentication**: Compatible with Strapi 5's new security model (JWT stored in cookies).
21
+
22
+ ## Installation & Setup
23
+
24
+ 1. **Build the Plugin**:
25
+ ```bash
26
+ cd src/plugins/presence
27
+ npm install
28
+ npm run build
29
+ ```
30
+
31
+ 2. **Enable the Plugin**: Ensure the plugin is enabled in your `config/plugins.ts`:
32
+ ```typescript
33
+ export default ({ env }) => ({
34
+ presence: {
35
+ enabled: true,
36
+ resolve: './src/plugins/presence'
37
+ },
38
+ });
39
+ ```
40
+
41
+ 3. **Rebuild Strapi Admin**:
42
+ ```bash
43
+ npm run build
44
+ npm run dev # or npm run staging
45
+ ```
46
+
47
+ ## Development
48
+
49
+ The main presence component is located at `admin/src/components/PresenceAvatars.tsx`. It uses standard HTML/CSS for styling to ensure maximum stability and zero conflicts with fluctuating `@strapi/design-system` versions.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ const jsxRuntime = require("react/jsx-runtime");
3
+ const react = require("react");
4
+ const reactRouterDom = require("react-router-dom");
5
+ const socket_ioClient = require("socket.io-client");
6
+ const avatarColors = [
7
+ "#4945ff",
8
+ // Strapi Purple
9
+ "#32d08d",
10
+ // Green
11
+ "#ff5d5d",
12
+ // Red
13
+ "#ffb54d",
14
+ // Orange
15
+ "#a155ff",
16
+ // Violet
17
+ "#211fad",
18
+ // Dark Blue
19
+ "#007bff"
20
+ // Sky Blue
21
+ ];
22
+ const PresenceAvatars = () => {
23
+ const params = reactRouterDom.useParams();
24
+ const entryId = params.id || params.documentId || params.slug;
25
+ const [allUsers, setAllUsers] = react.useState([]);
26
+ const [currentUser, setCurrentUser] = react.useState(null);
27
+ const [isConnected, setIsConnected] = react.useState(false);
28
+ react.useEffect(() => {
29
+ const getCookie = (name) => {
30
+ const value = `; ${document.cookie}`;
31
+ const parts = value.split(`; ${name}=`);
32
+ if (parts.length === 2) return parts.pop()?.split(";").shift();
33
+ return null;
34
+ };
35
+ const fetchMe = async () => {
36
+ try {
37
+ const token = getCookie("jwtToken") || localStorage.getItem("jwtToken");
38
+ const response = await fetch(`${window.location.origin}/admin/users/me`, {
39
+ headers: token ? { "Authorization": `Bearer ${token}` } : {}
40
+ });
41
+ if (response.ok) {
42
+ const resData = await response.json();
43
+ setCurrentUser({
44
+ id: resData.data.id || Math.random(),
45
+ username: resData.data.firstname || resData.data.username || "Admin",
46
+ initials: (resData.data.firstname?.[0] || "A").toUpperCase()
47
+ });
48
+ } else {
49
+ setCurrentUser({
50
+ id: "anon-" + Math.random().toString(36).substring(2, 7),
51
+ username: "Someone",
52
+ initials: "?"
53
+ });
54
+ }
55
+ } catch (err) {
56
+ }
57
+ };
58
+ fetchMe();
59
+ }, []);
60
+ const socket = react.useMemo(() => {
61
+ try {
62
+ return socket_ioClient.io(window.location.origin, { transports: ["websocket", "polling"] });
63
+ } catch {
64
+ return null;
65
+ }
66
+ }, []);
67
+ react.useEffect(() => {
68
+ if (!socket || !entryId || !currentUser) return;
69
+ const onConnect = () => {
70
+ setIsConnected(true);
71
+ socket.emit("join-entry", { entryId, user: currentUser });
72
+ };
73
+ const onUpdate = (users) => {
74
+ const uniqueUsers = Array.from(new Map((users || []).map((u) => [u.id, u])).values());
75
+ setAllUsers(uniqueUsers);
76
+ };
77
+ const onDisconnect = () => setIsConnected(false);
78
+ socket.on("connect", onConnect);
79
+ socket.on("presence-update", onUpdate);
80
+ socket.on("disconnect", onDisconnect);
81
+ if (socket.connected) onConnect();
82
+ return () => {
83
+ socket.off("connect", onConnect);
84
+ socket.off("presence-update", onUpdate);
85
+ socket.off("disconnect", onDisconnect);
86
+ socket.disconnect();
87
+ };
88
+ }, [entryId, currentUser, socket]);
89
+ if (!entryId) return null;
90
+ const getColor = (id) => {
91
+ const strId = String(id);
92
+ let hash = 0;
93
+ for (let i = 0; i < strId.length; i++) {
94
+ hash = strId.charCodeAt(i) + ((hash << 5) - hash);
95
+ }
96
+ return avatarColors[Math.abs(hash) % avatarColors.length];
97
+ };
98
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "presence-root-container", children: [
99
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
100
+ .presence-root-container {
101
+ padding: 8px 0 0 0;
102
+ margin-bottom: 0px;
103
+ width: 100%;
104
+ display: flex;
105
+ flex-direction: column;
106
+ align-items: flex-start;
107
+ justify-content: flex-start;
108
+ text-align: left;
109
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
110
+ }
111
+ .presence-header {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: flex-start;
115
+ gap: 6px;
116
+ margin-bottom: 10px;
117
+ width: 100%;
118
+ }
119
+ .presence-title {
120
+ font-size: 11px;
121
+ font-weight: 800;
122
+ color: #4945ff;
123
+ letter-spacing: 0.05em;
124
+ text-transform: uppercase;
125
+ margin: 0;
126
+ }
127
+ .presence-status-dot {
128
+ width: 7px;
129
+ height: 7px;
130
+ border-radius: 50%;
131
+ background: #32d08d;
132
+ box-shadow: 0 0 8px rgba(50, 208, 141, 0.4);
133
+ }
134
+ .presence-status-dot.active::after {
135
+ content: '';
136
+ position: absolute;
137
+ width: 7px;
138
+ height: 7px;
139
+ background: inherit;
140
+ border-radius: 50%;
141
+ animation: presence-pulse 2s infinite;
142
+ }
143
+ @keyframes presence-pulse {
144
+ 0% { transform: scale(1); opacity: 0.8; }
145
+ 100% { transform: scale(3); opacity: 0; }
146
+ }
147
+ .presence-avatar-list {
148
+ display: flex;
149
+ flex-wrap: wrap;
150
+ gap: 6px;
151
+ justify-content: flex-start;
152
+ align-items: center;
153
+ width: 100%;
154
+ }
155
+ .presence-avatar-item {
156
+ position: relative;
157
+ width: 30px;
158
+ height: 30px;
159
+ border-radius: 50%;
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ color: white;
164
+ font-size: 11px;
165
+ font-weight: 700;
166
+ cursor: pointer;
167
+ transition: transform 0.2s ease;
168
+ box-shadow: 0 2px 6px rgba(0,0,0,0.1);
169
+ }
170
+ .presence-avatar-item:hover {
171
+ transform: translateY(-2px);
172
+ z-index: 5;
173
+ }
174
+ .presence-tooltip {
175
+ position: absolute;
176
+ bottom: 100%;
177
+ left: 50%;
178
+ transform: translateX(-50%) translateY(0);
179
+ background: #212134;
180
+ color: white;
181
+ padding: 5px 10px;
182
+ border-radius: 4px;
183
+ font-size: 10px;
184
+ white-space: nowrap;
185
+ opacity: 0;
186
+ visibility: hidden;
187
+ transition: all 0.2s ease;
188
+ pointer-events: none;
189
+ }
190
+ .presence-avatar-item:hover .presence-tooltip {
191
+ opacity: 1;
192
+ visibility: visible;
193
+ transform: translateX(-50%) translateY(-8px);
194
+ }
195
+ .presence-me-badge {
196
+ position: absolute;
197
+ bottom: -1px;
198
+ right: -1px;
199
+ width: 9px;
200
+ height: 9px;
201
+ background: white;
202
+ border-radius: 50%;
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: center;
206
+ border: 1.5px solid #4945ff;
207
+ }
208
+ .presence-me-inner {
209
+ width: 3px;
210
+ height: 3px;
211
+ background: #4945ff;
212
+ border-radius: 50%;
213
+ }
214
+ ` }),
215
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "presence-header", children: [
216
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "presence-title", children: "Live Editing" }),
217
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: `presence-status-dot ${isConnected ? "active" : ""}`, style: { background: isConnected ? "#32d08d" : "#f5c0b8" } })
218
+ ] }),
219
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "presence-avatar-list", children: !isConnected ? /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "11px", color: "#8e8ea9" }, children: "Connecting..." }) : allUsers.length > 0 ? allUsers.map((u, idx) => {
220
+ const isMe = currentUser && u.id === currentUser.id;
221
+ return /* @__PURE__ */ jsxRuntime.jsxs(
222
+ "div",
223
+ {
224
+ className: "presence-avatar-item",
225
+ style: { background: isMe ? "#4945ff" : getColor(u.id) },
226
+ children: [
227
+ u.initials,
228
+ isMe && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "presence-me-badge", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "presence-me-inner" }) }),
229
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "presence-tooltip", children: [
230
+ u.username,
231
+ " ",
232
+ isMe ? "(You)" : ""
233
+ ] })
234
+ ]
235
+ },
236
+ `${u.id}-${idx}`
237
+ );
238
+ }) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "11px", color: "#8e8ea9" }, children: "Ready" }) })
239
+ ] });
240
+ };
241
+ console.log("[Presence] Admin Plugin Index Loading...");
242
+ const index = {
243
+ register(app) {
244
+ },
245
+ bootstrap(app) {
246
+ app.getPlugin("content-manager").injectComponent("editView", "right-links", {
247
+ name: "presence-avatars-right",
248
+ Component: PresenceAvatars
249
+ });
250
+ }
251
+ };
252
+ module.exports = index;
@@ -0,0 +1,253 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from "react";
3
+ import { useParams } from "react-router-dom";
4
+ import { io } from "socket.io-client";
5
+ const avatarColors = [
6
+ "#4945ff",
7
+ // Strapi Purple
8
+ "#32d08d",
9
+ // Green
10
+ "#ff5d5d",
11
+ // Red
12
+ "#ffb54d",
13
+ // Orange
14
+ "#a155ff",
15
+ // Violet
16
+ "#211fad",
17
+ // Dark Blue
18
+ "#007bff"
19
+ // Sky Blue
20
+ ];
21
+ const PresenceAvatars = () => {
22
+ const params = useParams();
23
+ const entryId = params.id || params.documentId || params.slug;
24
+ const [allUsers, setAllUsers] = useState([]);
25
+ const [currentUser, setCurrentUser] = useState(null);
26
+ const [isConnected, setIsConnected] = useState(false);
27
+ useEffect(() => {
28
+ const getCookie = (name) => {
29
+ const value = `; ${document.cookie}`;
30
+ const parts = value.split(`; ${name}=`);
31
+ if (parts.length === 2) return parts.pop()?.split(";").shift();
32
+ return null;
33
+ };
34
+ const fetchMe = async () => {
35
+ try {
36
+ const token = getCookie("jwtToken") || localStorage.getItem("jwtToken");
37
+ const response = await fetch(`${window.location.origin}/admin/users/me`, {
38
+ headers: token ? { "Authorization": `Bearer ${token}` } : {}
39
+ });
40
+ if (response.ok) {
41
+ const resData = await response.json();
42
+ setCurrentUser({
43
+ id: resData.data.id || Math.random(),
44
+ username: resData.data.firstname || resData.data.username || "Admin",
45
+ initials: (resData.data.firstname?.[0] || "A").toUpperCase()
46
+ });
47
+ } else {
48
+ setCurrentUser({
49
+ id: "anon-" + Math.random().toString(36).substring(2, 7),
50
+ username: "Someone",
51
+ initials: "?"
52
+ });
53
+ }
54
+ } catch (err) {
55
+ }
56
+ };
57
+ fetchMe();
58
+ }, []);
59
+ const socket = useMemo(() => {
60
+ try {
61
+ return io(window.location.origin, { transports: ["websocket", "polling"] });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }, []);
66
+ useEffect(() => {
67
+ if (!socket || !entryId || !currentUser) return;
68
+ const onConnect = () => {
69
+ setIsConnected(true);
70
+ socket.emit("join-entry", { entryId, user: currentUser });
71
+ };
72
+ const onUpdate = (users) => {
73
+ const uniqueUsers = Array.from(new Map((users || []).map((u) => [u.id, u])).values());
74
+ setAllUsers(uniqueUsers);
75
+ };
76
+ const onDisconnect = () => setIsConnected(false);
77
+ socket.on("connect", onConnect);
78
+ socket.on("presence-update", onUpdate);
79
+ socket.on("disconnect", onDisconnect);
80
+ if (socket.connected) onConnect();
81
+ return () => {
82
+ socket.off("connect", onConnect);
83
+ socket.off("presence-update", onUpdate);
84
+ socket.off("disconnect", onDisconnect);
85
+ socket.disconnect();
86
+ };
87
+ }, [entryId, currentUser, socket]);
88
+ if (!entryId) return null;
89
+ const getColor = (id) => {
90
+ const strId = String(id);
91
+ let hash = 0;
92
+ for (let i = 0; i < strId.length; i++) {
93
+ hash = strId.charCodeAt(i) + ((hash << 5) - hash);
94
+ }
95
+ return avatarColors[Math.abs(hash) % avatarColors.length];
96
+ };
97
+ return /* @__PURE__ */ jsxs("div", { className: "presence-root-container", children: [
98
+ /* @__PURE__ */ jsx("style", { children: `
99
+ .presence-root-container {
100
+ padding: 8px 0 0 0;
101
+ margin-bottom: 0px;
102
+ width: 100%;
103
+ display: flex;
104
+ flex-direction: column;
105
+ align-items: flex-start;
106
+ justify-content: flex-start;
107
+ text-align: left;
108
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
109
+ }
110
+ .presence-header {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: flex-start;
114
+ gap: 6px;
115
+ margin-bottom: 10px;
116
+ width: 100%;
117
+ }
118
+ .presence-title {
119
+ font-size: 11px;
120
+ font-weight: 800;
121
+ color: #4945ff;
122
+ letter-spacing: 0.05em;
123
+ text-transform: uppercase;
124
+ margin: 0;
125
+ }
126
+ .presence-status-dot {
127
+ width: 7px;
128
+ height: 7px;
129
+ border-radius: 50%;
130
+ background: #32d08d;
131
+ box-shadow: 0 0 8px rgba(50, 208, 141, 0.4);
132
+ }
133
+ .presence-status-dot.active::after {
134
+ content: '';
135
+ position: absolute;
136
+ width: 7px;
137
+ height: 7px;
138
+ background: inherit;
139
+ border-radius: 50%;
140
+ animation: presence-pulse 2s infinite;
141
+ }
142
+ @keyframes presence-pulse {
143
+ 0% { transform: scale(1); opacity: 0.8; }
144
+ 100% { transform: scale(3); opacity: 0; }
145
+ }
146
+ .presence-avatar-list {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ gap: 6px;
150
+ justify-content: flex-start;
151
+ align-items: center;
152
+ width: 100%;
153
+ }
154
+ .presence-avatar-item {
155
+ position: relative;
156
+ width: 30px;
157
+ height: 30px;
158
+ border-radius: 50%;
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ color: white;
163
+ font-size: 11px;
164
+ font-weight: 700;
165
+ cursor: pointer;
166
+ transition: transform 0.2s ease;
167
+ box-shadow: 0 2px 6px rgba(0,0,0,0.1);
168
+ }
169
+ .presence-avatar-item:hover {
170
+ transform: translateY(-2px);
171
+ z-index: 5;
172
+ }
173
+ .presence-tooltip {
174
+ position: absolute;
175
+ bottom: 100%;
176
+ left: 50%;
177
+ transform: translateX(-50%) translateY(0);
178
+ background: #212134;
179
+ color: white;
180
+ padding: 5px 10px;
181
+ border-radius: 4px;
182
+ font-size: 10px;
183
+ white-space: nowrap;
184
+ opacity: 0;
185
+ visibility: hidden;
186
+ transition: all 0.2s ease;
187
+ pointer-events: none;
188
+ }
189
+ .presence-avatar-item:hover .presence-tooltip {
190
+ opacity: 1;
191
+ visibility: visible;
192
+ transform: translateX(-50%) translateY(-8px);
193
+ }
194
+ .presence-me-badge {
195
+ position: absolute;
196
+ bottom: -1px;
197
+ right: -1px;
198
+ width: 9px;
199
+ height: 9px;
200
+ background: white;
201
+ border-radius: 50%;
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ border: 1.5px solid #4945ff;
206
+ }
207
+ .presence-me-inner {
208
+ width: 3px;
209
+ height: 3px;
210
+ background: #4945ff;
211
+ border-radius: 50%;
212
+ }
213
+ ` }),
214
+ /* @__PURE__ */ jsxs("div", { className: "presence-header", children: [
215
+ /* @__PURE__ */ jsx("span", { className: "presence-title", children: "Live Editing" }),
216
+ /* @__PURE__ */ jsx("div", { className: `presence-status-dot ${isConnected ? "active" : ""}`, style: { background: isConnected ? "#32d08d" : "#f5c0b8" } })
217
+ ] }),
218
+ /* @__PURE__ */ jsx("div", { className: "presence-avatar-list", children: !isConnected ? /* @__PURE__ */ jsx("span", { style: { fontSize: "11px", color: "#8e8ea9" }, children: "Connecting..." }) : allUsers.length > 0 ? allUsers.map((u, idx) => {
219
+ const isMe = currentUser && u.id === currentUser.id;
220
+ return /* @__PURE__ */ jsxs(
221
+ "div",
222
+ {
223
+ className: "presence-avatar-item",
224
+ style: { background: isMe ? "#4945ff" : getColor(u.id) },
225
+ children: [
226
+ u.initials,
227
+ isMe && /* @__PURE__ */ jsx("div", { className: "presence-me-badge", children: /* @__PURE__ */ jsx("div", { className: "presence-me-inner" }) }),
228
+ /* @__PURE__ */ jsxs("div", { className: "presence-tooltip", children: [
229
+ u.username,
230
+ " ",
231
+ isMe ? "(You)" : ""
232
+ ] })
233
+ ]
234
+ },
235
+ `${u.id}-${idx}`
236
+ );
237
+ }) : /* @__PURE__ */ jsx("span", { style: { fontSize: "11px", color: "#8e8ea9" }, children: "Ready" }) })
238
+ ] });
239
+ };
240
+ console.log("[Presence] Admin Plugin Index Loading...");
241
+ const index = {
242
+ register(app) {
243
+ },
244
+ bootstrap(app) {
245
+ app.getPlugin("content-manager").injectComponent("editView", "right-links", {
246
+ name: "presence-avatars-right",
247
+ Component: PresenceAvatars
248
+ });
249
+ }
250
+ };
251
+ export {
252
+ index as default
253
+ };
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ const index = {
3
+ register() {
4
+ },
5
+ async bootstrap({ strapi }) {
6
+ let Server;
7
+ try {
8
+ Server = require("socket.io").Server;
9
+ } catch (err) {
10
+ console.error('Socket.io not found. Please run "npm install socket.io" in the backend directory.');
11
+ return;
12
+ }
13
+ const io = new Server(strapi.server.httpServer, {
14
+ cors: {
15
+ origin: "*",
16
+ // In production, restrict this to your admin URL
17
+ methods: ["GET", "POST"]
18
+ }
19
+ });
20
+ const activeUsers = /* @__PURE__ */ new Map();
21
+ io.on("connection", (socket) => {
22
+ socket.on("join-entry", ({ entryId, user }) => {
23
+ activeUsers.set(socket.id, { entryId, ...user });
24
+ socket.join(`entry-${entryId}`);
25
+ const usersInRoom = Array.from(activeUsers.values()).filter((u) => u.entryId === entryId);
26
+ io.to(`entry-${entryId}`).emit("presence-update", usersInRoom);
27
+ console.log(`[Presence] User ${user.username} joined entry ${entryId}`);
28
+ });
29
+ socket.on("disconnect", () => {
30
+ const user = activeUsers.get(socket.id);
31
+ if (user) {
32
+ const { entryId } = user;
33
+ activeUsers.delete(socket.id);
34
+ const usersInRoom = Array.from(activeUsers.values()).filter((u) => u.entryId === entryId);
35
+ io.to(`entry-${entryId}`).emit("presence-update", usersInRoom);
36
+ console.log(`[Presence] User ${user.username} left`);
37
+ }
38
+ });
39
+ });
40
+ strapi.io = io;
41
+ console.log("[Presence] Socket.io server initialized");
42
+ }
43
+ };
44
+ module.exports = index;
@@ -0,0 +1,45 @@
1
+ const index = {
2
+ register() {
3
+ },
4
+ async bootstrap({ strapi }) {
5
+ let Server;
6
+ try {
7
+ Server = require("socket.io").Server;
8
+ } catch (err) {
9
+ console.error('Socket.io not found. Please run "npm install socket.io" in the backend directory.');
10
+ return;
11
+ }
12
+ const io = new Server(strapi.server.httpServer, {
13
+ cors: {
14
+ origin: "*",
15
+ // In production, restrict this to your admin URL
16
+ methods: ["GET", "POST"]
17
+ }
18
+ });
19
+ const activeUsers = /* @__PURE__ */ new Map();
20
+ io.on("connection", (socket) => {
21
+ socket.on("join-entry", ({ entryId, user }) => {
22
+ activeUsers.set(socket.id, { entryId, ...user });
23
+ socket.join(`entry-${entryId}`);
24
+ const usersInRoom = Array.from(activeUsers.values()).filter((u) => u.entryId === entryId);
25
+ io.to(`entry-${entryId}`).emit("presence-update", usersInRoom);
26
+ console.log(`[Presence] User ${user.username} joined entry ${entryId}`);
27
+ });
28
+ socket.on("disconnect", () => {
29
+ const user = activeUsers.get(socket.id);
30
+ if (user) {
31
+ const { entryId } = user;
32
+ activeUsers.delete(socket.id);
33
+ const usersInRoom = Array.from(activeUsers.values()).filter((u) => u.entryId === entryId);
34
+ io.to(`entry-${entryId}`).emit("presence-update", usersInRoom);
35
+ console.log(`[Presence] User ${user.username} left`);
36
+ }
37
+ });
38
+ });
39
+ strapi.io = io;
40
+ console.log("[Presence] Socket.io server initialized");
41
+ }
42
+ };
43
+ export {
44
+ index as default
45
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@thinhnd028/strapi-plugin-presence",
3
+ "version": "0.0.1",
4
+ "description": "Real-time presence for Strapi 5 Admin Panel",
5
+ "author": "Tai Nguyen",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/thinhnd028/strapi-plugin-presence.git"
10
+ },
11
+ "publishConfig": {
12
+ "registry": "https://registry.npmjs.org/",
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist/",
17
+ "package.json",
18
+ "README.md",
19
+ "strapi-admin.ts",
20
+ "strapi-server.ts"
21
+ ],
22
+ "strapi": {
23
+ "kind": "plugin",
24
+ "name": "presence",
25
+ "displayName": "Presence",
26
+ "description": "Real-time presence for Strapi 5 Admin Panel"
27
+ },
28
+ "dependencies": {
29
+ "socket.io": "^4.7.2",
30
+ "socket.io-client": "^4.7.2"
31
+ },
32
+ "devDependencies": {
33
+ "@strapi/sdk-plugin": "^5.2.0",
34
+ "@types/react": "^18.0.0",
35
+ "@types/react-dom": "^18.0.0",
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@strapi/strapi": "^5.0.0",
40
+ "react": "^17.0.0 || ^18.0.0",
41
+ "react-dom": "^17.0.0 || ^18.0.0",
42
+ "react-router-dom": "^6.0.0"
43
+ },
44
+ "scripts": {
45
+ "build": "strapi-plugin build",
46
+ "watch": "strapi-plugin watch",
47
+ "verify": "strapi-plugin verify"
48
+ },
49
+ "type": "commonjs",
50
+ "exports": {
51
+ "./package.json": "./package.json",
52
+ "./strapi-admin": {
53
+ "types": "./dist/admin/src/index.d.ts",
54
+ "source": "./admin/src/index.ts",
55
+ "import": "./dist/admin/index.mjs",
56
+ "require": "./dist/admin/index.js",
57
+ "default": "./dist/admin/index.js"
58
+ },
59
+ "./strapi-server": {
60
+ "types": "./dist/server/src/index.d.ts",
61
+ "source": "./server/index.ts",
62
+ "import": "./dist/server/index.mjs",
63
+ "require": "./dist/server/index.js",
64
+ "default": "./dist/server/index.js"
65
+ }
66
+ }
67
+ }
@@ -0,0 +1 @@
1
+ export { default } from './admin/src';
@@ -0,0 +1 @@
1
+ export { default } from './server';