@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 +53 -0
- package/dist/admin/index.js +252 -0
- package/dist/admin/index.mjs +253 -0
- package/dist/server/index.js +44 -0
- package/dist/server/index.mjs +45 -0
- package/package.json +67 -0
- package/strapi-admin.ts +1 -0
- package/strapi-server.ts +1 -0
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
|
+
}
|
package/strapi-admin.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './admin/src';
|
package/strapi-server.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './server';
|