openkbs 0.0.67 → 0.0.70
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 +1 -0
- package/elastic/README.md +1 -1
- package/elastic/functions.md +5 -5
- package/elastic/pulse.md +2 -2
- package/package.json +2 -2
- package/scripts/deploy.js +68 -0
- package/src/actions.js +7 -0
- package/templates/.claude/skills/openkbs/SKILL.md +37 -8
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/README.md +55 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/app/instructions.txt +40 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/app/settings.json +41 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/openkbs.json +3 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/actions.js +141 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/handler.js +32 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/memoryHelpers.js +91 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/onCronjob.js +105 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/onPublicAPIRequest.js +165 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/onRequest.js +2 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Events/onResponse.js +2 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Frontend/contentRender.js +74 -0
- package/templates/.claude/skills/openkbs/examples/monitoring-bot/src/Frontend/contentRender.json +3 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/functions/auth/index.mjs +228 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/functions/auth/package.json +7 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/functions/posts/index.mjs +287 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/functions/posts/package.json +10 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/functions/settings.json +4 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/openkbs.json +16 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/site/index.html +658 -0
- package/templates/.claude/skills/openkbs/examples/nodejs-demo/site/settings.json +4 -0
- package/templates/.claude/skills/openkbs/patterns/cronjob-batch-processing.md +278 -0
- package/templates/.claude/skills/openkbs/patterns/cronjob-monitoring.md +341 -0
- package/templates/.claude/skills/openkbs/patterns/file-upload.md +205 -0
- package/templates/.claude/skills/openkbs/patterns/image-generation.md +139 -0
- package/templates/.claude/skills/openkbs/patterns/memory-system.md +264 -0
- package/templates/.claude/skills/openkbs/patterns/public-api-item-proxy.md +254 -0
- package/templates/.claude/skills/openkbs/patterns/scheduled-tasks.md +157 -0
- package/templates/.claude/skills/openkbs/patterns/telegram-webhook.md +424 -0
- package/templates/.claude/skills/openkbs/patterns/telegram.md +222 -0
- package/templates/.claude/skills/openkbs/patterns/vectordb-archive.md +231 -0
- package/templates/.claude/skills/openkbs/patterns/video-generation.md +145 -0
- package/templates/.claude/skills/openkbs/patterns/web-publishing.md +257 -0
- package/templates/.claude/skills/openkbs/reference/backend-sdk.md +13 -2
- package/templates/.claude/skills/openkbs/reference/elastic-services.md +61 -29
- package/templates/platform/README.md +15 -50
- package/templates/platform/agents/assistant/app/icon.png +0 -0
- package/templates/platform/agents/assistant/app/instructions.txt +9 -21
- package/templates/platform/agents/assistant/app/settings.json +11 -15
- package/templates/platform/agents/assistant/src/Events/actions.js +31 -62
- package/templates/platform/agents/assistant/src/Events/handler.js +54 -0
- package/templates/platform/agents/assistant/src/Events/onRequest.js +3 -0
- package/templates/platform/agents/assistant/src/Events/onRequest.json +5 -0
- package/templates/platform/agents/assistant/src/Events/onResponse.js +2 -40
- package/templates/platform/agents/assistant/src/Events/onResponse.json +4 -2
- package/templates/platform/agents/assistant/src/Frontend/contentRender.js +17 -16
- package/templates/platform/agents/assistant/src/Frontend/contentRender.json +1 -1
- package/templates/platform/functions/api/index.mjs +17 -23
- package/templates/platform/functions/api/package.json +4 -0
- package/templates/platform/openkbs.json +7 -2
- package/templates/platform/site/index.html +18 -19
- package/version.json +3 -3
- package/templates/.claude/skills/openkbs/examples/ai-copywriter-agent/scripts/run_job.js +0 -26
- package/templates/.claude/skills/openkbs/examples/ai-copywriter-agent/scripts/utils/agent_client.js +0 -265
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>NodeJS Demo - Social + Chat</title>
|
|
7
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
8
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
9
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
10
|
+
<script src="https://unpkg.com/openkbs-pulse/pulse.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
13
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
|
|
14
|
+
|
|
15
|
+
.app { display: flex; height: 100vh; }
|
|
16
|
+
|
|
17
|
+
/* Sidebar */
|
|
18
|
+
.sidebar { width: 280px; background: white; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; }
|
|
19
|
+
.sidebar-header { padding: 15px; border-bottom: 1px solid #e0e0e0; }
|
|
20
|
+
.sidebar-header h1 { font-size: 20px; color: #333; }
|
|
21
|
+
.online-count { font-size: 12px; color: #666; margin-top: 5px; }
|
|
22
|
+
|
|
23
|
+
.tabs { display: flex; border-bottom: 1px solid #e0e0e0; }
|
|
24
|
+
.tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; font-size: 14px; color: #666; border-bottom: 2px solid transparent; }
|
|
25
|
+
.tab.active { color: #007bff; border-bottom-color: #007bff; }
|
|
26
|
+
|
|
27
|
+
.users-list { flex: 1; overflow-y: auto; }
|
|
28
|
+
.user-item { display: flex; align-items: center; padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #f0f0f0; }
|
|
29
|
+
.user-item:hover { background: #f8f8f8; }
|
|
30
|
+
.user-item.selected { background: #e3f2fd; }
|
|
31
|
+
.user-item.has-unread { font-weight: bold; }
|
|
32
|
+
|
|
33
|
+
.avatar { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 16px; margin-right: 12px; position: relative; }
|
|
34
|
+
.avatar.small { width: 32px; height: 32px; font-size: 13px; margin-right: 8px; }
|
|
35
|
+
.online-dot { position: absolute; bottom: 0; right: 0; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white; }
|
|
36
|
+
|
|
37
|
+
.user-info { flex: 1; }
|
|
38
|
+
.user-name { font-weight: 500; color: #333; }
|
|
39
|
+
.user-status { font-size: 12px; color: #999; }
|
|
40
|
+
.unread-badge { background: #007bff; color: white; font-size: 11px; padding: 2px 6px; border-radius: 10px; }
|
|
41
|
+
|
|
42
|
+
/* Main content */
|
|
43
|
+
.main { flex: 1; display: flex; flex-direction: column; }
|
|
44
|
+
|
|
45
|
+
.main-header { padding: 15px 20px; background: white; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }
|
|
46
|
+
.main-header h2 { font-size: 18px; color: #333; }
|
|
47
|
+
.connection-status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #666; }
|
|
48
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
49
|
+
.status-dot.online { background: #4CAF50; }
|
|
50
|
+
.status-dot.offline { background: #f44336; }
|
|
51
|
+
|
|
52
|
+
.content { flex: 1; overflow-y: auto; padding: 20px; }
|
|
53
|
+
|
|
54
|
+
/* Posts */
|
|
55
|
+
.posts { max-width: 600px; margin: 0 auto; }
|
|
56
|
+
.post-form { background: white; padding: 15px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
57
|
+
.post-form textarea { width: 100%; padding: 12px; border: 1px solid #e0e0e0; border-radius: 8px; resize: none; font-size: 14px; }
|
|
58
|
+
.post-form-actions { display: flex; justify-content: flex-end; margin-top: 10px; gap: 10px; }
|
|
59
|
+
.post-form button { padding: 8px 20px; background: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
|
60
|
+
.post-form button:disabled { background: #ccc; }
|
|
61
|
+
.image-upload { margin-top: 10px; }
|
|
62
|
+
.image-upload input[type="file"] { display: none; }
|
|
63
|
+
.upload-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; font-size: 13px; color: #666; }
|
|
64
|
+
.upload-btn:hover { background: #e8e8e8; }
|
|
65
|
+
.image-preview { margin-top: 10px; position: relative; display: inline-block; }
|
|
66
|
+
.image-preview img { max-width: 200px; max-height: 150px; border-radius: 8px; }
|
|
67
|
+
.image-preview .remove-btn { position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; background: #dc3545; color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 14px; line-height: 1; }
|
|
68
|
+
.uploading { opacity: 0.6; pointer-events: none; }
|
|
69
|
+
|
|
70
|
+
.post { background: white; padding: 15px; border-radius: 10px; margin-bottom: 15px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
71
|
+
.post-header { display: flex; align-items: center; margin-bottom: 10px; }
|
|
72
|
+
.post-meta { flex: 1; }
|
|
73
|
+
.post-author { font-weight: 600; color: #333; }
|
|
74
|
+
.post-time { font-size: 12px; color: #999; }
|
|
75
|
+
.post-content { color: #444; line-height: 1.5; }
|
|
76
|
+
.post-image { margin-top: 10px; max-width: 100%; border-radius: 8px; }
|
|
77
|
+
|
|
78
|
+
/* Chat */
|
|
79
|
+
.chat { display: flex; flex-direction: column; height: 100%; max-width: 800px; margin: 0 auto; width: 100%; }
|
|
80
|
+
.chat-header { display: flex; align-items: center; padding: 15px; background: white; border-radius: 10px 10px 0 0; }
|
|
81
|
+
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; background: #fafafa; }
|
|
82
|
+
.chat-input { display: flex; gap: 10px; padding: 15px; background: white; border-radius: 0 0 10px 10px; }
|
|
83
|
+
.chat-input input { flex: 1; padding: 12px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 14px; }
|
|
84
|
+
.chat-input button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; }
|
|
85
|
+
|
|
86
|
+
.message { display: flex; margin-bottom: 15px; max-width: 100%; }
|
|
87
|
+
.message.sent { flex-direction: row-reverse; }
|
|
88
|
+
.message-content { max-width: 70%; }
|
|
89
|
+
.message-bubble { padding: 12px 16px; border-radius: 18px; word-wrap: break-word; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
|
|
90
|
+
.message.received .message-bubble { background: white; color: #333; border: 1px solid #e0e0e0; }
|
|
91
|
+
.message.sent .message-bubble { background: #007bff; color: white; }
|
|
92
|
+
.message-time { font-size: 11px; color: #999; margin-top: 4px; padding: 0 4px; }
|
|
93
|
+
.message.sent .message-time { text-align: right; }
|
|
94
|
+
|
|
95
|
+
.no-chat { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; font-size: 16px; }
|
|
96
|
+
|
|
97
|
+
/* Auth */
|
|
98
|
+
.auth-container { display: flex; align-items: center; justify-content: center; height: 100vh; background: #f5f5f5; }
|
|
99
|
+
.auth-form { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
|
|
100
|
+
.auth-form h2 { margin-bottom: 20px; color: #333; }
|
|
101
|
+
.auth-form input { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 14px; }
|
|
102
|
+
.auth-form button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
|
103
|
+
.auth-form button:disabled { background: #ccc; }
|
|
104
|
+
.auth-toggle { margin-top: 15px; text-align: center; color: #666; font-size: 14px; }
|
|
105
|
+
.auth-toggle a { color: #007bff; cursor: pointer; }
|
|
106
|
+
.error { color: #dc3545; font-size: 14px; margin-bottom: 15px; }
|
|
107
|
+
|
|
108
|
+
.logout-btn { padding: 8px 16px; background: #dc3545; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
109
|
+
|
|
110
|
+
.empty { text-align: center; padding: 40px; color: #999; }
|
|
111
|
+
</style>
|
|
112
|
+
</head>
|
|
113
|
+
<body>
|
|
114
|
+
<div id="root"></div>
|
|
115
|
+
|
|
116
|
+
<script type="text/babel">
|
|
117
|
+
const { useState, useEffect, useRef } = React;
|
|
118
|
+
|
|
119
|
+
const API_AUTH = '/auth';
|
|
120
|
+
const API_POSTS = '/posts';
|
|
121
|
+
// Replace with your kbId from: openkbs ls
|
|
122
|
+
const KB_ID = 'YOUR_KB_ID';
|
|
123
|
+
|
|
124
|
+
function App() {
|
|
125
|
+
const [user, setUser] = useState(null);
|
|
126
|
+
const [view, setView] = useState('posts'); // 'posts' or 'chat'
|
|
127
|
+
const [posts, setPosts] = useState([]);
|
|
128
|
+
const [users, setUsers] = useState([]);
|
|
129
|
+
const [onlineUsers, setOnlineUsers] = useState(new Set());
|
|
130
|
+
const [connected, setConnected] = useState(false);
|
|
131
|
+
const [selectedUser, setSelectedUser] = useState(null);
|
|
132
|
+
const [messages, setMessages] = useState({});
|
|
133
|
+
const [unreadFrom, setUnreadFrom] = useState(new Set());
|
|
134
|
+
const pulseRef = useRef(null);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const saved = localStorage.getItem('nodejs_demo_user');
|
|
138
|
+
if (saved) setUser(JSON.parse(saved));
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
// Connect to Pulse when user logs in
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!user?.pulseToken) return;
|
|
144
|
+
|
|
145
|
+
// Load posts
|
|
146
|
+
fetch(API_POSTS, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ action: 'list' })
|
|
150
|
+
})
|
|
151
|
+
.then(r => r.json())
|
|
152
|
+
.then(data => setPosts(data.posts || []))
|
|
153
|
+
.catch(console.error);
|
|
154
|
+
|
|
155
|
+
// Load users list
|
|
156
|
+
fetch(API_AUTH, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
body: JSON.stringify({ action: 'users' })
|
|
160
|
+
})
|
|
161
|
+
.then(r => r.json())
|
|
162
|
+
.then(data => {
|
|
163
|
+
const filtered = (data.users || []).filter(u => u.id !== user.id);
|
|
164
|
+
setUsers(filtered);
|
|
165
|
+
})
|
|
166
|
+
.catch(console.error);
|
|
167
|
+
|
|
168
|
+
// Connect to Pulse (Ably-compatible API)
|
|
169
|
+
const realtime = new Pulse.Realtime({
|
|
170
|
+
kbId: KB_ID,
|
|
171
|
+
token: user.pulseToken,
|
|
172
|
+
endpoint: user.pulseEndpoint,
|
|
173
|
+
clientId: String(user.id),
|
|
174
|
+
debug: false
|
|
175
|
+
});
|
|
176
|
+
pulseRef.current = realtime;
|
|
177
|
+
|
|
178
|
+
// Connection state
|
|
179
|
+
realtime.connection.on('connected', () => setConnected(true));
|
|
180
|
+
realtime.connection.on('disconnected', () => setConnected(false));
|
|
181
|
+
|
|
182
|
+
// ===== POSTS CHANNEL =====
|
|
183
|
+
const postsChannel = realtime.channels.get('posts');
|
|
184
|
+
|
|
185
|
+
// Subscribe to new posts
|
|
186
|
+
postsChannel.subscribe('new_post', (message) => {
|
|
187
|
+
const post = message.data?.post || message.data;
|
|
188
|
+
if (post?.id) {
|
|
189
|
+
setPosts(prev => {
|
|
190
|
+
if (prev.some(p => p.id === post.id)) return prev;
|
|
191
|
+
return [post, ...prev];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Presence: enter and subscribe
|
|
197
|
+
postsChannel.presence.enter({ userId: String(user.id), name: user.name });
|
|
198
|
+
|
|
199
|
+
postsChannel.presence.subscribe((members) => {
|
|
200
|
+
const ids = new Set(members.map(m => Number(m.data?.userId || m.clientId)));
|
|
201
|
+
setOnlineUsers(ids);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
postsChannel.presence.subscribe('enter', (member) => {
|
|
205
|
+
const userId = Number(member.data?.userId || member.clientId);
|
|
206
|
+
setOnlineUsers(prev => new Set([...prev, userId]));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
postsChannel.presence.subscribe('leave', (member) => {
|
|
210
|
+
const userId = Number(member.data?.userId || member.clientId);
|
|
211
|
+
setOnlineUsers(prev => {
|
|
212
|
+
const next = new Set(prev);
|
|
213
|
+
next.delete(userId);
|
|
214
|
+
return next;
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ===== PRIVATE CHANNEL =====
|
|
219
|
+
let privateChannel = null;
|
|
220
|
+
if (user.privateChannel) {
|
|
221
|
+
console.log('Subscribing to private channel:', user.privateChannel.substring(0, 8) + '...');
|
|
222
|
+
privateChannel = realtime.channels.get(user.privateChannel);
|
|
223
|
+
|
|
224
|
+
// Subscribe to messages
|
|
225
|
+
privateChannel.subscribe('new_message', (message) => {
|
|
226
|
+
console.log('Received private message:', message);
|
|
227
|
+
const msg = message.data;
|
|
228
|
+
const fromId = msg.fromUserId;
|
|
229
|
+
setMessages(prev => {
|
|
230
|
+
const existing = prev[fromId] || [];
|
|
231
|
+
if (existing.some(m => m.id === msg.id)) return prev;
|
|
232
|
+
return { ...prev, [fromId]: [...existing, msg] };
|
|
233
|
+
});
|
|
234
|
+
setUnreadFrom(prev => new Set([...prev, fromId]));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Cleanup
|
|
239
|
+
return () => {
|
|
240
|
+
postsChannel.presence.leave();
|
|
241
|
+
if (privateChannel) privateChannel.detach();
|
|
242
|
+
realtime.close();
|
|
243
|
+
};
|
|
244
|
+
}, [user?.pulseToken]);
|
|
245
|
+
|
|
246
|
+
const handleLogin = (userData) => {
|
|
247
|
+
setUser(userData);
|
|
248
|
+
localStorage.setItem('nodejs_demo_user', JSON.stringify(userData));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleLogout = () => {
|
|
252
|
+
pulseRef.current?.close();
|
|
253
|
+
setUser(null);
|
|
254
|
+
setPosts([]);
|
|
255
|
+
setUsers([]);
|
|
256
|
+
localStorage.removeItem('nodejs_demo_user');
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const handleSelectUser = async (u) => {
|
|
260
|
+
setSelectedUser(u);
|
|
261
|
+
setView('chat');
|
|
262
|
+
setUnreadFrom(prev => {
|
|
263
|
+
const next = new Set(prev);
|
|
264
|
+
next.delete(u.id);
|
|
265
|
+
return next;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Load chat history
|
|
269
|
+
if (!messages[u.id]) {
|
|
270
|
+
const res = await fetch(API_POSTS, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
273
|
+
body: JSON.stringify({ action: 'getMessages', userId: user.id, withUserId: u.id })
|
|
274
|
+
});
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
setMessages(prev => ({ ...prev, [u.id]: data.messages || [] }));
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const handleSendMessage = async (content) => {
|
|
281
|
+
if (!selectedUser || !content.trim()) return;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch(API_POSTS, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
action: 'sendMessage',
|
|
289
|
+
toUserId: selectedUser.id,
|
|
290
|
+
message: content,
|
|
291
|
+
fromUserId: user.id,
|
|
292
|
+
fromUserName: user.name
|
|
293
|
+
})
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const data = await res.json();
|
|
297
|
+
console.log('Send message response:', data);
|
|
298
|
+
if (data.message) {
|
|
299
|
+
setMessages(prev => {
|
|
300
|
+
const existing = prev[selectedUser.id] || [];
|
|
301
|
+
// Avoid duplicates
|
|
302
|
+
if (existing.some(m => m.id === data.message.id)) return prev;
|
|
303
|
+
return {
|
|
304
|
+
...prev,
|
|
305
|
+
[selectedUser.id]: [...existing, data.message]
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
} else if (data.error) {
|
|
309
|
+
console.error('Send error:', data.error);
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error('Send failed:', err);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (!user) return <AuthForm onLogin={handleLogin} />;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="app">
|
|
320
|
+
<Sidebar
|
|
321
|
+
user={user}
|
|
322
|
+
users={users}
|
|
323
|
+
onlineUsers={onlineUsers}
|
|
324
|
+
selectedUser={selectedUser}
|
|
325
|
+
unreadFrom={unreadFrom}
|
|
326
|
+
view={view}
|
|
327
|
+
setView={setView}
|
|
328
|
+
onSelectUser={handleSelectUser}
|
|
329
|
+
onLogout={handleLogout}
|
|
330
|
+
/>
|
|
331
|
+
<main className="main">
|
|
332
|
+
<div className="main-header">
|
|
333
|
+
<h2>{view === 'posts' ? 'Feed' : selectedUser ? `Chat with ${selectedUser.name}` : 'Messages'}</h2>
|
|
334
|
+
<div className="connection-status">
|
|
335
|
+
<span className={`status-dot ${connected ? 'online' : 'offline'}`}></span>
|
|
336
|
+
{connected ? 'Connected' : 'Connecting...'}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div className="content">
|
|
340
|
+
{view === 'posts' ? (
|
|
341
|
+
<PostsView user={user} posts={posts} />
|
|
342
|
+
) : (
|
|
343
|
+
<ChatView
|
|
344
|
+
user={user}
|
|
345
|
+
selectedUser={selectedUser}
|
|
346
|
+
messages={messages[selectedUser?.id] || []}
|
|
347
|
+
onSend={handleSendMessage}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</main>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function Sidebar({ user, users, onlineUsers, selectedUser, unreadFrom, view, setView, onSelectUser, onLogout }) {
|
|
357
|
+
return (
|
|
358
|
+
<div className="sidebar">
|
|
359
|
+
<div className="sidebar-header">
|
|
360
|
+
<h1>NodeJS Demo</h1>
|
|
361
|
+
<div className="online-count">{onlineUsers.size} users online</div>
|
|
362
|
+
</div>
|
|
363
|
+
<div className="tabs">
|
|
364
|
+
<div className={`tab ${view === 'posts' ? 'active' : ''}`} onClick={() => setView('posts')}>Feed</div>
|
|
365
|
+
<div className={`tab ${view === 'chat' ? 'active' : ''}`} onClick={() => setView('chat')}>Chat</div>
|
|
366
|
+
</div>
|
|
367
|
+
<div className="users-list">
|
|
368
|
+
{/* Current user */}
|
|
369
|
+
<div className="user-item" style={{ background: '#f8f8f8' }}>
|
|
370
|
+
<Avatar name={user.name} color={user.avatarColor} isOnline={true} />
|
|
371
|
+
<div className="user-info">
|
|
372
|
+
<div className="user-name">{user.name} (you)</div>
|
|
373
|
+
<div className="user-status">Online</div>
|
|
374
|
+
</div>
|
|
375
|
+
<button className="logout-btn" onClick={onLogout}>Logout</button>
|
|
376
|
+
</div>
|
|
377
|
+
{/* Other users */}
|
|
378
|
+
{users.map(u => (
|
|
379
|
+
<div
|
|
380
|
+
key={u.id}
|
|
381
|
+
className={`user-item ${selectedUser?.id === u.id ? 'selected' : ''} ${unreadFrom.has(u.id) ? 'has-unread' : ''}`}
|
|
382
|
+
onClick={() => onSelectUser(u)}
|
|
383
|
+
>
|
|
384
|
+
<Avatar name={u.name} color={u.avatarColor} isOnline={onlineUsers.has(u.id)} />
|
|
385
|
+
<div className="user-info">
|
|
386
|
+
<div className="user-name">{u.name}</div>
|
|
387
|
+
<div className="user-status">{onlineUsers.has(u.id) ? 'Online' : 'Offline'}</div>
|
|
388
|
+
</div>
|
|
389
|
+
{unreadFrom.has(u.id) && <span className="unread-badge">New</span>}
|
|
390
|
+
</div>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function Avatar({ name, color, isOnline, small }) {
|
|
398
|
+
const initial = (name || '?')[0].toUpperCase();
|
|
399
|
+
return (
|
|
400
|
+
<div className={`avatar ${small ? 'small' : ''}`} style={{ backgroundColor: color || '#007bff' }}>
|
|
401
|
+
{initial}
|
|
402
|
+
{isOnline && <span className="online-dot"></span>}
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function PostsView({ user, posts }) {
|
|
408
|
+
const [content, setContent] = useState('');
|
|
409
|
+
const [loading, setLoading] = useState(false);
|
|
410
|
+
const [selectedImage, setSelectedImage] = useState(null);
|
|
411
|
+
const [imagePreview, setImagePreview] = useState(null);
|
|
412
|
+
const fileInputRef = useRef(null);
|
|
413
|
+
|
|
414
|
+
const handleImageSelect = (e) => {
|
|
415
|
+
const file = e.target.files[0];
|
|
416
|
+
if (file && file.type.startsWith('image/')) {
|
|
417
|
+
setSelectedImage(file);
|
|
418
|
+
const reader = new FileReader();
|
|
419
|
+
reader.onload = (e) => setImagePreview(e.target.result);
|
|
420
|
+
reader.readAsDataURL(file);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const removeImage = () => {
|
|
425
|
+
setSelectedImage(null);
|
|
426
|
+
setImagePreview(null);
|
|
427
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const uploadImage = async (file) => {
|
|
431
|
+
// Get presigned upload URL
|
|
432
|
+
const urlRes = await fetch(API_POSTS, {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({
|
|
436
|
+
action: 'getUploadUrl',
|
|
437
|
+
fileName: file.name,
|
|
438
|
+
contentType: file.type
|
|
439
|
+
})
|
|
440
|
+
});
|
|
441
|
+
const { uploadUrl, publicUrl } = await urlRes.json();
|
|
442
|
+
|
|
443
|
+
// Upload to S3
|
|
444
|
+
await fetch(uploadUrl, {
|
|
445
|
+
method: 'PUT',
|
|
446
|
+
body: file,
|
|
447
|
+
headers: { 'Content-Type': file.type }
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return publicUrl;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const handleSubmit = async (e) => {
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
if (!content.trim() && !selectedImage) return;
|
|
456
|
+
setLoading(true);
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
let imageUrl = null;
|
|
460
|
+
if (selectedImage) {
|
|
461
|
+
imageUrl = await uploadImage(selectedImage);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await fetch(API_POSTS, {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { 'Content-Type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
action: 'create',
|
|
469
|
+
content,
|
|
470
|
+
imageUrl,
|
|
471
|
+
userId: user.id,
|
|
472
|
+
userName: user.name
|
|
473
|
+
})
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
setContent('');
|
|
477
|
+
removeImage();
|
|
478
|
+
} finally {
|
|
479
|
+
setLoading(false);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div className="posts">
|
|
485
|
+
<form className="post-form" onSubmit={handleSubmit}>
|
|
486
|
+
<textarea
|
|
487
|
+
placeholder="What's on your mind?"
|
|
488
|
+
value={content}
|
|
489
|
+
onChange={e => setContent(e.target.value)}
|
|
490
|
+
rows={3}
|
|
491
|
+
/>
|
|
492
|
+
<div className="image-upload">
|
|
493
|
+
<input
|
|
494
|
+
type="file"
|
|
495
|
+
ref={fileInputRef}
|
|
496
|
+
accept="image/*"
|
|
497
|
+
onChange={handleImageSelect}
|
|
498
|
+
/>
|
|
499
|
+
{!imagePreview && (
|
|
500
|
+
<label className="upload-btn" onClick={() => fileInputRef.current?.click()}>
|
|
501
|
+
📷 Add Photo
|
|
502
|
+
</label>
|
|
503
|
+
)}
|
|
504
|
+
{imagePreview && (
|
|
505
|
+
<div className="image-preview">
|
|
506
|
+
<img src={imagePreview} alt="Preview" />
|
|
507
|
+
<button type="button" className="remove-btn" onClick={removeImage}>×</button>
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
<div className="post-form-actions">
|
|
512
|
+
<button type="submit" disabled={loading || (!content.trim() && !selectedImage)}>
|
|
513
|
+
{loading ? 'Posting...' : 'Post'}
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
516
|
+
</form>
|
|
517
|
+
{posts.length === 0 ? (
|
|
518
|
+
<div className="empty">No posts yet. Be the first!</div>
|
|
519
|
+
) : (
|
|
520
|
+
posts.map(post => (
|
|
521
|
+
<div key={post.id} className="post">
|
|
522
|
+
<div className="post-header">
|
|
523
|
+
<Avatar name={post.userName} color="#007bff" small />
|
|
524
|
+
<div className="post-meta">
|
|
525
|
+
<div className="post-author">{post.userName}</div>
|
|
526
|
+
<div className="post-time">{timeAgo(post.createdAt)}</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
<div className="post-content">{post.content}</div>
|
|
530
|
+
{post.imageUrl && <img className="post-image" src={post.imageUrl} alt="" />}
|
|
531
|
+
</div>
|
|
532
|
+
))
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ChatView({ user, selectedUser, messages, onSend }) {
|
|
539
|
+
const [input, setInput] = useState('');
|
|
540
|
+
const messagesEndRef = useRef(null);
|
|
541
|
+
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
544
|
+
}, [messages]);
|
|
545
|
+
|
|
546
|
+
if (!selectedUser) {
|
|
547
|
+
return <div className="no-chat">Select a user to start chatting</div>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const handleSubmit = (e) => {
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
if (!input.trim()) return;
|
|
553
|
+
onSend(input);
|
|
554
|
+
setInput('');
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
<div className="chat">
|
|
559
|
+
<div className="chat-header">
|
|
560
|
+
<Avatar name={selectedUser.name} color={selectedUser.avatarColor} />
|
|
561
|
+
<div className="user-info">
|
|
562
|
+
<div className="user-name">{selectedUser.name}</div>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
<div className="chat-messages">
|
|
566
|
+
{messages.map((msg, i) => (
|
|
567
|
+
<div key={msg.id || i} className={`message ${msg.fromUserId === user.id ? 'sent' : 'received'}`}>
|
|
568
|
+
<div className="message-content">
|
|
569
|
+
<div className="message-bubble">{msg.content}</div>
|
|
570
|
+
<div className="message-time">{timeAgo(msg.createdAt)}</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
))}
|
|
574
|
+
<div ref={messagesEndRef} />
|
|
575
|
+
</div>
|
|
576
|
+
<form className="chat-input" onSubmit={handleSubmit}>
|
|
577
|
+
<input
|
|
578
|
+
type="text"
|
|
579
|
+
placeholder="Type a message..."
|
|
580
|
+
value={input}
|
|
581
|
+
onChange={e => setInput(e.target.value)}
|
|
582
|
+
/>
|
|
583
|
+
<button type="submit">Send</button>
|
|
584
|
+
</form>
|
|
585
|
+
</div>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function AuthForm({ onLogin }) {
|
|
590
|
+
const [isRegister, setIsRegister] = useState(false);
|
|
591
|
+
const [name, setName] = useState('');
|
|
592
|
+
const [email, setEmail] = useState('');
|
|
593
|
+
const [password, setPassword] = useState('');
|
|
594
|
+
const [error, setError] = useState('');
|
|
595
|
+
const [loading, setLoading] = useState(false);
|
|
596
|
+
|
|
597
|
+
const handleSubmit = async (e) => {
|
|
598
|
+
e.preventDefault();
|
|
599
|
+
setError('');
|
|
600
|
+
setLoading(true);
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const res = await fetch(API_AUTH, {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { 'Content-Type': 'application/json' },
|
|
606
|
+
body: JSON.stringify({
|
|
607
|
+
action: isRegister ? 'register' : 'login',
|
|
608
|
+
name, email, password
|
|
609
|
+
})
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const data = await res.json();
|
|
613
|
+
if (data.error) {
|
|
614
|
+
setError(data.error);
|
|
615
|
+
} else if (data.user) {
|
|
616
|
+
onLogin(data.user);
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
setError('Connection error');
|
|
620
|
+
} finally {
|
|
621
|
+
setLoading(false);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<div className="auth-container">
|
|
627
|
+
<form className="auth-form" onSubmit={handleSubmit}>
|
|
628
|
+
<h2>{isRegister ? 'Create Account' : 'Welcome Back'}</h2>
|
|
629
|
+
{error && <div className="error">{error}</div>}
|
|
630
|
+
{isRegister && (
|
|
631
|
+
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} required />
|
|
632
|
+
)}
|
|
633
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required />
|
|
634
|
+
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
|
|
635
|
+
<button type="submit" disabled={loading}>
|
|
636
|
+
{loading ? 'Loading...' : (isRegister ? 'Register' : 'Login')}
|
|
637
|
+
</button>
|
|
638
|
+
<div className="auth-toggle">
|
|
639
|
+
{isRegister ? 'Already have an account? ' : "Don't have an account? "}
|
|
640
|
+
<a onClick={() => setIsRegister(!isRegister)}>{isRegister ? 'Login' : 'Register'}</a>
|
|
641
|
+
</div>
|
|
642
|
+
</form>
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function timeAgo(date) {
|
|
648
|
+
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
|
|
649
|
+
if (seconds < 60) return 'just now';
|
|
650
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
651
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
652
|
+
return new Date(date).toLocaleDateString();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
656
|
+
</script>
|
|
657
|
+
</body>
|
|
658
|
+
</html>
|