Sebebeb commited on
Commit
72cf80f
·
verified ·
1 Parent(s): 136c1a8

Upload templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +707 -0
templates/index.html ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Private Chat</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ height: 100vh; display: flex; align-items: center; justify-content: center;
12
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
13
+ color: #e2e8f0;
14
+ }
15
+ button { cursor: pointer; font-family: inherit; }
16
+ input { font-family: inherit; }
17
+
18
+ /* ---------- LANDING ---------- */
19
+ #landing {
20
+ background: #1e293b; border-radius: 20px; padding: 48px 40px;
21
+ width: 420px; max-width: 94vw; box-shadow: 0 25px 60px rgba(0,0,0,0.5);
22
+ text-align: center; border: 1px solid #334155;
23
+ }
24
+ #landing h1 { font-size: 28px; font-weight: 700; margin-bottom: 6px; }
25
+ #landing p { color: #94a3b8; margin-bottom: 28px; font-size: 15px; }
26
+ #landing input {
27
+ width: 100%; padding: 14px 18px; border-radius: 12px; border: 2px solid #334155;
28
+ background: #0f172a; color: #e2e8f0; font-size: 16px; outline: none;
29
+ transition: border-color 0.2s; margin-bottom: 16px;
30
+ }
31
+ #landing input:focus { border-color: #3b82f6; }
32
+ #landing button {
33
+ width: 100%; padding: 14px; border-radius: 12px; border: none;
34
+ background: #3b82f6; color: #fff; font-size: 16px; font-weight: 600;
35
+ transition: background 0.2s;
36
+ }
37
+ #landing button:hover { background: #2563eb; }
38
+ #landing .error { color: #ef4444; font-size: 14px; margin-top: 10px; }
39
+
40
+ /* ---------- CHAT LAYOUT ---------- */
41
+ #chat { display: none; width: 100vw; height: 100vh; }
42
+ .chat-container {
43
+ display: flex; height: 100vh; max-width: 1200px; margin: 0 auto;
44
+ width: 100%;
45
+ }
46
+
47
+ /* Sidebar */
48
+ .sidebar {
49
+ width: 320px; background: #1e293b; border-right: 1px solid #334155;
50
+ display: flex; flex-direction: column; flex-shrink: 0;
51
+ }
52
+ .sidebar-header {
53
+ padding: 20px; border-bottom: 1px solid #334155;
54
+ display: flex; align-items: center; justify-content: space-between;
55
+ }
56
+ .sidebar-header h2 { font-size: 18px; font-weight: 600; }
57
+ .sidebar-header .count { color: #64748b; font-size: 14px; }
58
+ .user-list { flex: 1; overflow-y: auto; padding: 8px; }
59
+ .user-item {
60
+ display: flex; align-items: center; gap: 12px; padding: 12px 14px;
61
+ border-radius: 12px; cursor: pointer; transition: background 0.15s;
62
+ position: relative;
63
+ }
64
+ .user-item:hover { background: #334155; }
65
+ .user-item.active { background: #1e3a5f; }
66
+ .user-item .avatar {
67
+ width: 40px; height: 40px; border-radius: 50%;
68
+ display: flex; align-items: center; justify-content: center;
69
+ font-weight: 600; font-size: 16px; flex-shrink: 0;
70
+ background: #3b82f6; color: #fff;
71
+ }
72
+ .user-item .info { flex: 1; min-width: 0; }
73
+ .user-item .name { font-size: 15px; font-weight: 500; }
74
+ .user-item .status { font-size: 12px; color: #22c55e; }
75
+ .user-item .typing-dot {
76
+ font-size: 12px; color: #94a3b8; font-style: italic;
77
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
78
+ }
79
+ .user-item .online-dot {
80
+ width: 8px; height: 8px; border-radius: 50%; background: #22c55e;
81
+ flex-shrink: 0;
82
+ }
83
+
84
+ /* Main chat area */
85
+ .main {
86
+ flex: 1; display: flex; flex-direction: column; min-width: 0;
87
+ background: #0f172a;
88
+ }
89
+ .chat-header {
90
+ padding: 16px 24px; border-bottom: 1px solid #334155;
91
+ display: flex; align-items: center; justify-content: space-between;
92
+ min-height: 72px; background: #1e293b;
93
+ }
94
+ .chat-header .partner-info { display: flex; align-items: center; gap: 12px; }
95
+ .chat-header .partner-avatar {
96
+ width: 36px; height: 36px; border-radius: 50%;
97
+ display: flex; align-items: center; justify-content: center;
98
+ font-weight: 600; font-size: 14px; background: #3b82f6; color: #fff;
99
+ }
100
+ .chat-header .partner-name { font-size: 16px; font-weight: 600; }
101
+ .chat-header .partner-status { font-size: 13px; color: #22c55e; }
102
+
103
+ .chat-header .self-info {
104
+ display: flex; align-items: center; gap: 10px;
105
+ }
106
+ .chat-header .self-name { font-size: 14px; color: #94a3b8; }
107
+ .chat-header .self-name button {
108
+ background: none; border: none; color: #3b82f6; font-size: 13px;
109
+ margin-left: 6px; text-decoration: underline;
110
+ }
111
+
112
+ .messages {
113
+ flex: 1; overflow-y: auto; padding: 20px 24px;
114
+ display: flex; flex-direction: column; gap: 4px;
115
+ }
116
+ .msg-row { display: flex; margin-bottom: 4px; }
117
+ .msg-row.self { justify-content: flex-end; }
118
+ .msg-row.other { justify-content: flex-start; }
119
+ .msg-bubble {
120
+ max-width: 70%; padding: 10px 16px; border-radius: 18px;
121
+ font-size: 15px; line-height: 1.45; word-wrap: break-word;
122
+ position: relative;
123
+ }
124
+ .msg-row.self .msg-bubble {
125
+ background: #3b82f6; color: #fff;
126
+ border-bottom-right-radius: 4px;
127
+ }
128
+ .msg-row.other .msg-bubble {
129
+ background: #1e293b; color: #e2e8f0;
130
+ border-bottom-left-radius: 4px;
131
+ }
132
+ .msg-time {
133
+ font-size: 11px; color: #64748b; margin-top: 4px; text-align: right;
134
+ }
135
+ .msg-row.other .msg-time { text-align: left; }
136
+
137
+ .empty-chat {
138
+ flex: 1; display: flex; flex-direction: column; align-items: center;
139
+ justify-content: center; color: #475569; gap: 12px;
140
+ }
141
+ .empty-chat .icon { font-size: 48px; opacity: 0.4; }
142
+ .empty-chat p { font-size: 15px; }
143
+
144
+ .typing-indicator {
145
+ padding: 4px 24px 8px; font-size: 13px; color: #64748b;
146
+ font-style: italic; height: 24px;
147
+ }
148
+
149
+ .input-area {
150
+ padding: 16px 24px; background: #1e293b; border-top: 1px solid #334155;
151
+ display: flex; gap: 12px; align-items: flex-end;
152
+ }
153
+ .input-area textarea {
154
+ flex: 1; resize: none; padding: 12px 16px; border-radius: 12px;
155
+ border: 2px solid #334155; background: #0f172a; color: #e2e8f0;
156
+ font-size: 15px; outline: none; min-height: 24px; max-height: 120px;
157
+ line-height: 1.4; transition: border-color 0.2s;
158
+ }
159
+ .input-area textarea:focus { border-color: #3b82f6; }
160
+ .input-area textarea::placeholder { color: #475569; }
161
+ .input-area button {
162
+ padding: 12px 20px; border-radius: 12px; border: none;
163
+ background: #3b82f6; color: #fff; font-size: 15px; font-weight: 600;
164
+ transition: background 0.2s; white-space: nowrap;
165
+ }
166
+ .input-area button:hover { background: #2563eb; }
167
+ .input-area button:disabled { opacity: 0.4; cursor: not-allowed; }
168
+
169
+ /* ---------- RENAME MODAL ---------- */
170
+ .modal-overlay {
171
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
172
+ align-items: center; justify-content: center; z-index: 100;
173
+ }
174
+ .modal-overlay.show { display: flex; }
175
+ .modal {
176
+ background: #1e293b; border-radius: 16px; padding: 32px; width: 360px;
177
+ max-width: 92vw; border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
178
+ }
179
+ .modal h3 { font-size: 20px; margin-bottom: 6px; }
180
+ .modal p { color: #94a3b8; font-size: 14px; margin-bottom: 20px; }
181
+ .modal input {
182
+ width: 100%; padding: 12px 16px; border-radius: 10px; border: 2px solid #334155;
183
+ background: #0f172a; color: #e2e8f0; font-size: 15px; outline: none;
184
+ transition: border-color 0.2s; margin-bottom: 16px;
185
+ }
186
+ .modal input:focus { border-color: #3b82f6; }
187
+ .modal-buttons { display: flex; gap: 10px; }
188
+ .modal-buttons button {
189
+ flex: 1; padding: 12px; border-radius: 10px; border: none;
190
+ font-size: 14px; font-weight: 600;
191
+ }
192
+ .modal-buttons .cancel { background: #334155; color: #94a3b8; }
193
+ .modal-buttons .cancel:hover { background: #475569; }
194
+ .modal-buttons .save { background: #3b82f6; color: #fff; }
195
+ .modal-buttons .save:hover { background: #2563eb; }
196
+
197
+ /* ---------- SCROLLBAR ---------- */
198
+ ::-webkit-scrollbar { width: 6px; }
199
+ ::-webkit-scrollbar-track { background: transparent; }
200
+ ::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
201
+ ::-webkit-scrollbar-thumb:hover { background: #475569; }
202
+
203
+ @media (max-width: 700px) {
204
+ .sidebar { width: 80px; }
205
+ .sidebar-header h2, .sidebar-header .count, .user-item .info { display: none; }
206
+ .user-item { justify-content: center; padding: 10px; }
207
+ .chat-header .self-info .self-name { display: none; }
208
+ }
209
+ </style>
210
+ </head>
211
+ <body>
212
+
213
+ <!-- LANDING -->
214
+ <div id="landing">
215
+ <h1>Private Chat</h1>
216
+ <p>Choose a display name to join</p>
217
+ <input id="nameInput" type="text" placeholder="Your display name..." maxlength="32" autofocus>
218
+ <button id="joinBtn">Join Chat</button>
219
+ <div id="landingError" class="error"></div>
220
+ </div>
221
+
222
+ <!-- CHAT -->
223
+ <div id="chat">
224
+ <div class="chat-container">
225
+ <!-- Sidebar -->
226
+ <div class="sidebar">
227
+ <div class="sidebar-header">
228
+ <h2>Online</h2>
229
+ <span class="count" id="userCount">0</span>
230
+ </div>
231
+ <div class="user-list" id="userList"></div>
232
+ </div>
233
+
234
+ <!-- Main -->
235
+ <div class="main">
236
+ <div class="chat-header">
237
+ <div class="partner-info" id="partnerInfo">
238
+ <div class="partner-avatar" id="partnerAvatar">?</div>
239
+ <div>
240
+ <div class="partner-name" id="partnerName">Select a user</div>
241
+ <div class="partner-status" id="partnerStatus"></div>
242
+ </div>
243
+ </div>
244
+ <div class="self-info">
245
+ <span class="self-name" id="selfDisplay">
246
+ <span id="selfNameLabel"></span>
247
+ <button id="renameBtn">change</button>
248
+ </span>
249
+ </div>
250
+ </div>
251
+
252
+ <div class="messages" id="messages">
253
+ <div class="empty-chat" id="emptyChat">
254
+ <div class="icon">&#x1F4AC;</div>
255
+ <p>Select someone from the sidebar to start chatting</p>
256
+ </div>
257
+ </div>
258
+
259
+ <div class="typing-indicator" id="typingIndicator"></div>
260
+
261
+ <div class="input-area">
262
+ <textarea id="msgInput" placeholder="Type a message..." rows="1" maxlength="4000" disabled></textarea>
263
+ <button id="sendBtn" disabled>Send</button>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <!-- RENAME MODAL -->
270
+ <div class="modal-overlay" id="renameModal">
271
+ <div class="modal">
272
+ <h3>Change display name</h3>
273
+ <p>Choose a new name others will see.</p>
274
+ <input id="renameInput" type="text" maxlength="32" placeholder="New display name...">
275
+ <div class="modal-buttons">
276
+ <button class="cancel" id="renameCancel">Cancel</button>
277
+ <button class="save" id="renameSave">Save</button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <script>
283
+ (function() {
284
+ // ---------- STATE ----------
285
+ let userId = null;
286
+ let myName = '';
287
+ let ws = null;
288
+ let activeUserId = null; // who we're chatting with
289
+ let onlineUsers = new Map(); // id -> {name, el}
290
+ let messageHistory = new Map(); // userId -> [{from, content, ts, fromName}]
291
+ let typingTimers = new Map();
292
+ let reconnectTimer = null;
293
+ let isConnected = false;
294
+
295
+ const COLORS = [
296
+ '#3b82f6','#8b5cf6','#ec4899','#f43f5e','#f97316',
297
+ '#eab308','#22c55e','#14b8a6','#06b6d4','#6366f1'
298
+ ];
299
+
300
+ // ---------- DOM REFS ----------
301
+ const landing = document.getElementById('landing');
302
+ const chat = document.getElementById('chat');
303
+ const nameInput = document.getElementById('nameInput');
304
+ const joinBtn = document.getElementById('joinBtn');
305
+ const landingError = document.getElementById('landingError');
306
+ const userList = document.getElementById('userList');
307
+ const userCount = document.getElementById('userCount');
308
+ const messagesEl = document.getElementById('messages');
309
+ const emptyChat = document.getElementById('emptyChat');
310
+ const msgInput = document.getElementById('msgInput');
311
+ const sendBtn = document.getElementById('sendBtn');
312
+ const partnerName = document.getElementById('partnerName');
313
+ const partnerAvatar = document.getElementById('partnerAvatar');
314
+ const partnerStatus = document.getElementById('partnerStatus');
315
+ const selfNameLabel = document.getElementById('selfNameLabel');
316
+ const renameBtn = document.getElementById('renameBtn');
317
+ const typingIndicator = document.getElementById('typingIndicator');
318
+ const renameModal = document.getElementById('renameModal');
319
+ const renameInput = document.getElementById('renameInput');
320
+ const renameCancel = document.getElementById('renameCancel');
321
+ const renameSave = document.getElementById('renameSave');
322
+ const partnerInfo = document.getElementById('partnerInfo');
323
+
324
+ // ---------- HELPERS ----------
325
+ function avatarColor(id) {
326
+ let h = 0;
327
+ for (let i = 0; i < id.length; i++) h = ((h << 5) - h) + id.charCodeAt(i);
328
+ return COLORS[Math.abs(h) % COLORS.length];
329
+ }
330
+
331
+ function fmtTime(iso) {
332
+ try {
333
+ const d = new Date(iso);
334
+ return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
335
+ } catch { return ''; }
336
+ }
337
+
338
+ function escapeHtml(s) {
339
+ const d = document.createElement('div');
340
+ d.textContent = s;
341
+ return d.innerHTML;
342
+ }
343
+
344
+ // ---------- RENDER USERS ----------
345
+ function renderUsers() {
346
+ let html = '';
347
+ let count = 0;
348
+ for (const [id, u] of onlineUsers) {
349
+ if (id === activeUserId) {
350
+ // update header
351
+ partnerName.textContent = u.name;
352
+ partnerAvatar.textContent = u.name[0].toUpperCase();
353
+ partnerAvatar.style.background = avatarColor(id);
354
+ partnerStatus.textContent = 'Online';
355
+ }
356
+ count++;
357
+ const active = id === activeUserId ? 'active' : '';
358
+ html += `
359
+ <div class="user-item ${active}" data-id="${escapeHtml(id)}">
360
+ <div class="avatar" style="background:${avatarColor(id)}">${escapeHtml(u.name[0]?.toUpperCase() || '?')}</div>
361
+ <div class="info">
362
+ <div class="name">${escapeHtml(u.name)}</div>
363
+ <div class="status">Online</div>
364
+ <div class="typing-dot" id="typing_${escapeHtml(id)}"></div>
365
+ </div>
366
+ <div class="online-dot"></div>
367
+ </div>
368
+ `;
369
+ }
370
+ userList.innerHTML = html;
371
+ userCount.textContent = count;
372
+
373
+ // click handlers
374
+ userList.querySelectorAll('.user-item').forEach(el => {
375
+ el.addEventListener('click', () => {
376
+ const id = el.dataset.id;
377
+ if (id && id !== activeUserId) {
378
+ selectUser(id);
379
+ }
380
+ });
381
+ });
382
+ }
383
+
384
+ // ---------- SELECT USER ----------
385
+ function selectUser(id) {
386
+ activeUserId = id;
387
+ // re-render
388
+ renderUsers();
389
+ // load messages
390
+ renderMessages(id);
391
+ // enable input
392
+ msgInput.disabled = false;
393
+ sendBtn.disabled = false;
394
+ msgInput.focus();
395
+ // clear typing
396
+ typingIndicator.textContent = '';
397
+ // scroll
398
+ scrollMessages();
399
+ }
400
+
401
+ // ---------- MESSAGES ----------
402
+ function getMessages(partnerId) {
403
+ if (!messageHistory.has(partnerId)) messageHistory.set(partnerId, []);
404
+ return messageHistory.get(partnerId);
405
+ }
406
+
407
+ function addMessage(partnerId, msg) {
408
+ const msgs = getMessages(partnerId);
409
+ msgs.push(msg);
410
+ if (partnerId === activeUserId) {
411
+ renderMessages(partnerId);
412
+ scrollMessages();
413
+ }
414
+ }
415
+
416
+ function renderMessages(partnerId) {
417
+ const msgs = getMessages(partnerId);
418
+ emptyChat.style.display = msgs.length ? 'none' : 'flex';
419
+
420
+ if (!msgs.length) {
421
+ messagesEl.querySelectorAll('.msg-row').forEach(e => e.remove());
422
+ return;
423
+ }
424
+
425
+ let html = '';
426
+ for (const m of msgs) {
427
+ const isSelf = m.from === userId;
428
+ const cls = isSelf ? 'self' : 'other';
429
+ const name = isSelf ? 'You' : m.fromName;
430
+ html += `
431
+ <div class="msg-row ${cls}">
432
+ <div class="msg-bubble">
433
+ <div>${escapeHtml(m.content)}</div>
434
+ <div class="msg-time">${fmtTime(m.ts)}</div>
435
+ </div>
436
+ </div>
437
+ `;
438
+ }
439
+ // remove old messages but keep emptyChat
440
+ messagesEl.querySelectorAll('.msg-row').forEach(e => e.remove());
441
+ if (emptyChat) {
442
+ emptyChat.insertAdjacentHTML('afterend', html);
443
+ } else {
444
+ messagesEl.innerHTML = html;
445
+ }
446
+ }
447
+
448
+ function scrollMessages() {
449
+ setTimeout(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }, 20);
450
+ }
451
+
452
+ // ---------- TYPING ----------
453
+ function showTyping(fromId, fromName) {
454
+ const el = document.getElementById(`typing_${fromId}`);
455
+ if (el) el.textContent = `${fromName} is typing...`;
456
+
457
+ if (fromId === activeUserId) {
458
+ typingIndicator.textContent = `${fromName} is typing...`;
459
+ }
460
+
461
+ if (typingTimers.has(fromId)) clearTimeout(typingTimers.get(fromId));
462
+ typingTimers.set(fromId, setTimeout(() => {
463
+ const el2 = document.getElementById(`typing_${fromId}`);
464
+ if (el2) el2.textContent = '';
465
+ if (fromId === activeUserId) typingIndicator.textContent = '';
466
+ typingTimers.delete(fromId);
467
+ }, 2000));
468
+ }
469
+
470
+ // ---------- SEND MESSAGE ----------
471
+ function sendMessage() {
472
+ const content = msgInput.value.trim();
473
+ if (!content || !activeUserId || !ws || ws.readyState !== WebSocket.OPEN) return;
474
+ ws.send(JSON.stringify({type:'message', target_id: activeUserId, content}));
475
+ msgInput.value = '';
476
+ msgInput.style.height = 'auto';
477
+ }
478
+
479
+ // ---------- WEBSOCKET ----------
480
+ function connectWS() {
481
+ if (ws) { ws.close(); ws = null; }
482
+
483
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
484
+ const url = `${protocol}//${location.host}/ws/${userId}`;
485
+ ws = new WebSocket(url);
486
+
487
+ ws.onopen = () => {
488
+ isConnected = true;
489
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
490
+ };
491
+
492
+ ws.onmessage = (e) => {
493
+ const msg = JSON.parse(e.data);
494
+
495
+ switch (msg.type) {
496
+ case 'message': {
497
+ const partnerId = msg.from_id === userId ? msg.target_id || activeUserId : msg.from_id;
498
+ // Only add if it's for current conversation
499
+ if (msg.from_id === userId) {
500
+ // our own message - add to the target's history
501
+ if (activeUserId) {
502
+ addMessage(activeUserId, {
503
+ from: userId, fromName: myName, content: msg.content, ts: msg.ts
504
+ });
505
+ }
506
+ } else {
507
+ addMessage(msg.from_id, {
508
+ from: msg.from_id, fromName: msg.from_name, content: msg.content, ts: msg.ts
509
+ });
510
+ }
511
+ break;
512
+ }
513
+ case 'typing':
514
+ showTyping(msg.from_id, msg.from_name);
515
+ break;
516
+ case 'user_online': {
517
+ if (!onlineUsers.has(msg.id)) {
518
+ onlineUsers.set(msg.id, {name: msg.name});
519
+ renderUsers();
520
+ }
521
+ break;
522
+ }
523
+ case 'user_offline': {
524
+ onlineUsers.delete(msg.id);
525
+ if (activeUserId === msg.id) {
526
+ activeUserId = null;
527
+ msgInput.disabled = true;
528
+ sendBtn.disabled = true;
529
+ partnerName.textContent = 'Select a user';
530
+ partnerAvatar.textContent = '?';
531
+ partnerAvatar.style.background = '#3b82f6';
532
+ partnerStatus.textContent = '';
533
+ messagesEl.querySelectorAll('.msg-row').forEach(e => e.remove());
534
+ emptyChat.style.display = 'flex';
535
+ typingIndicator.textContent = '';
536
+ }
537
+ renderUsers();
538
+ break;
539
+ }
540
+ case 'user_renamed': {
541
+ if (onlineUsers.has(msg.id)) {
542
+ onlineUsers.get(msg.id).name = msg.name;
543
+ renderUsers();
544
+ }
545
+ break;
546
+ }
547
+ }
548
+ };
549
+
550
+ ws.onclose = () => {
551
+ isConnected = false;
552
+ if (userId) {
553
+ reconnectTimer = setTimeout(connectWS, 3000);
554
+ }
555
+ };
556
+
557
+ ws.onerror = () => {};
558
+ }
559
+
560
+ // ---------- JOIN ----------
561
+ async function join(name) {
562
+ try {
563
+ const res = await fetch('/api/join', {
564
+ method: 'POST',
565
+ headers: {'Content-Type':'application/json'},
566
+ body: JSON.stringify({display_name: name})
567
+ });
568
+ if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Failed to join'); }
569
+ const data = await res.json();
570
+ userId = data.user_id;
571
+ myName = data.display_name;
572
+
573
+ landing.style.display = 'none';
574
+ chat.style.display = 'block';
575
+ selfNameLabel.textContent = myName;
576
+
577
+ // load users
578
+ fetchUsers();
579
+ // connect ws
580
+ connectWS();
581
+ } catch (err) {
582
+ landingError.textContent = err.message;
583
+ }
584
+ }
585
+
586
+ async function fetchUsers() {
587
+ try {
588
+ const res = await fetch(`/api/users/${userId}`);
589
+ if (!res.ok) throw new Error('Failed');
590
+ const data = await res.json();
591
+ onlineUsers.clear();
592
+ for (const u of data.users) {
593
+ onlineUsers.set(u.id, {name: u.name});
594
+ }
595
+ renderUsers();
596
+ } catch {}
597
+ }
598
+
599
+ // ---------- RENAME ----------
600
+ async function doRename(newName) {
601
+ try {
602
+ const res = await fetch('/api/rename', {
603
+ method: 'POST',
604
+ headers: {'Content-Type':'application/json'},
605
+ body: JSON.stringify({user_id: userId, display_name: newName})
606
+ });
607
+ if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Failed'); }
608
+ const data = await res.json();
609
+ myName = data.display_name;
610
+ selfNameLabel.textContent = myName;
611
+ if (ws && ws.readyState === WebSocket.OPEN) {
612
+ ws.send(JSON.stringify({type:'rename', name: myName}));
613
+ }
614
+ renameModal.classList.remove('show');
615
+ } catch (err) {
616
+ alert(err.message);
617
+ }
618
+ }
619
+
620
+ // ---------- EVENT BINDINGS ----------
621
+ joinBtn.addEventListener('click', () => {
622
+ const name = nameInput.value.trim();
623
+ if (!name) { landingError.textContent = 'Please enter a display name.'; return; }
624
+ landingError.textContent = '';
625
+ join(name);
626
+ });
627
+
628
+ nameInput.addEventListener('keydown', (e) => {
629
+ if (e.key === 'Enter') { e.preventDefault(); joinBtn.click(); }
630
+ });
631
+
632
+ sendBtn.addEventListener('click', sendMessage);
633
+
634
+ msgInput.addEventListener('keydown', (e) => {
635
+ if (e.key === 'Enter' && !e.shiftKey) {
636
+ e.preventDefault();
637
+ sendMessage();
638
+ }
639
+ });
640
+
641
+ msgInput.addEventListener('input', () => {
642
+ msgInput.style.height = 'auto';
643
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + 'px';
644
+ // typing indicator
645
+ if (ws && ws.readyState === WebSocket.OPEN && activeUserId) {
646
+ ws.send(JSON.stringify({type:'typing', target_id: activeUserId}));
647
+ }
648
+ });
649
+
650
+ renameBtn.addEventListener('click', () => {
651
+ renameInput.value = myName;
652
+ renameModal.classList.add('show');
653
+ setTimeout(() => renameInput.focus(), 100);
654
+ });
655
+
656
+ renameCancel.addEventListener('click', () => {
657
+ renameModal.classList.remove('show');
658
+ });
659
+
660
+ renameSave.addEventListener('click', () => {
661
+ const n = renameInput.value.trim();
662
+ if (!n) return;
663
+ doRename(n);
664
+ });
665
+
666
+ renameInput.addEventListener('keydown', (e) => {
667
+ if (e.key === 'Enter') { e.preventDefault(); renameSave.click(); }
668
+ });
669
+
670
+ // close modal on overlay click
671
+ renameModal.addEventListener('click', (e) => {
672
+ if (e.target === renameModal) renameModal.classList.remove('show');
673
+ });
674
+
675
+ // ---------- INIT ----------
676
+ // Check for saved session in sessionStorage (exists per tab)
677
+ const savedId = sessionStorage.getItem('chat_user_id');
678
+ const savedName = sessionStorage.getItem('chat_user_name');
679
+ if (savedId && savedName) {
680
+ userId = savedId;
681
+ myName = savedName;
682
+ landing.style.display = 'none';
683
+ chat.style.display = 'block';
684
+ selfNameLabel.textContent = myName;
685
+ fetchUsers();
686
+ connectWS();
687
+ }
688
+
689
+ // Save session
690
+ const origJoin = join;
691
+ join = async function(name) {
692
+ await origJoin(name);
693
+ if (userId) {
694
+ sessionStorage.setItem('chat_user_id', userId);
695
+ sessionStorage.setItem('chat_user_name', myName);
696
+ }
697
+ };
698
+
699
+ // Handle page close - let WS close gracefully
700
+ window.addEventListener('beforeunload', () => {
701
+ sessionStorage.removeItem('chat_user_id');
702
+ sessionStorage.removeItem('chat_user_name');
703
+ });
704
+ })();
705
+ </script>
706
+ </body>
707
+ </html>