smodusermc commited on
Commit
5876732
·
verified ·
1 Parent(s): d75fa58

Create script.js

Browse files
Files changed (1) hide show
  1. static/script.js +387 -0
static/script.js ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function() {
2
+ 'use strict';
3
+ // --- State ---
4
+ let currentUser = null;
5
+ let token = localStorage.getItem('token');
6
+ let ws = null;
7
+ let messages = {}; // id -> msg object
8
+ let messageIds = []; // ordered
9
+ let oldestMessageId = null;
10
+ let replyToId = null;
11
+ let typingTimer = null;
12
+ let pendingFile = null; // {file_path, file_type, file_name}
13
+
14
+ const API = window.location.origin;
15
+
16
+ // DOM elements
17
+ const authScreen = document.getElementById('auth-screen');
18
+ const chatScreen = document.getElementById('chat-screen');
19
+ const authForm = document.getElementById('auth-form');
20
+ const authUsername = document.getElementById('auth-username');
21
+ const authPassword = document.getElementById('auth-password');
22
+ const authDisplayName = document.getElementById('auth-display-name');
23
+ const displayNameGroup = document.getElementById('display-name-group');
24
+ const authSubmit = document.getElementById('auth-submit');
25
+ const authError = document.getElementById('auth-error');
26
+ const loginTab = document.getElementById('login-tab');
27
+ const signupTab = document.getElementById('signup-tab');
28
+ const messagesList = document.getElementById('messages-list');
29
+ const composeForm = document.getElementById('compose-form');
30
+ const messageInput = document.getElementById('message-input');
31
+ const sendBtn = document.getElementById('send-btn');
32
+ const attachBtn = document.getElementById('attach-btn');
33
+ const fileInput = document.getElementById('file-input');
34
+ const replyPreview = document.getElementById('reply-preview');
35
+ const replyPreviewText = document.getElementById('reply-preview-text');
36
+ const cancelReply = document.getElementById('cancel-reply');
37
+ const typingIndicator = document.getElementById('typing-indicator');
38
+ const onlineCountSpan = document.getElementById('online-count');
39
+ const loadOlderBtn = document.getElementById('load-older-btn');
40
+ const settingsBtn = document.getElementById('settings-btn');
41
+ const logoutBtn = document.getElementById('logout-btn');
42
+ const settingsModal = document.getElementById('settings-modal');
43
+ const saveSettingsBtn = document.getElementById('save-settings');
44
+ const closeSettingsBtn = document.getElementById('close-settings');
45
+ const themeSelect = document.getElementById('theme-select');
46
+ const customThemeSettings = document.getElementById('custom-theme-settings');
47
+ const customSelfColor = document.getElementById('custom-self-color');
48
+ const customOtherColor = document.getElementById('custom-other-color');
49
+ const customTextColor = document.getElementById('custom-text-color');
50
+ const settingDisplayName = document.getElementById('setting-display-name');
51
+ const settingAvatar = document.getElementById('setting-avatar');
52
+ const avatarPreview = document.getElementById('avatar-preview');
53
+ const uploadProgress = document.getElementById('upload-progress');
54
+ const uploadBar = document.getElementById('upload-bar');
55
+ const uploadFilename = document.getElementById('upload-filename');
56
+
57
+ let isLogin = true;
58
+
59
+ // --- Theme ---
60
+ function applyTheme(name) {
61
+ document.body.className = name;
62
+ localStorage.setItem('theme', name);
63
+ if (name === 'theme-custom') {
64
+ const self = localStorage.getItem('customSelfColor') || '#25D366';
65
+ const other = localStorage.getItem('customOtherColor') || '#E5E5EA';
66
+ const txt = localStorage.getItem('customTextColor') || '#000';
67
+ document.documentElement.style.setProperty('--bubble-self', self);
68
+ document.documentElement.style.setProperty('--bubble-other', other);
69
+ document.documentElement.style.setProperty('--bubble-text-self', txt);
70
+ document.documentElement.style.setProperty('--bubble-text-other', txt);
71
+ }
72
+ }
73
+ function loadTheme() {
74
+ const saved = localStorage.getItem('theme') || 'theme-light';
75
+ themeSelect.value = saved;
76
+ if (saved === 'theme-custom') customThemeSettings.classList.remove('hidden');
77
+ applyTheme(saved);
78
+ }
79
+
80
+ // --- Auth ---
81
+ loginTab.addEventListener('click', ()=>{
82
+ isLogin=true; loginTab.classList.add('active'); signupTab.classList.remove('active');
83
+ displayNameGroup.style.display='none'; authSubmit.textContent='Login';
84
+ });
85
+ signupTab.addEventListener('click', ()=>{
86
+ isLogin=false; signupTab.classList.add('active'); loginTab.classList.remove('active');
87
+ displayNameGroup.style.display='block'; authSubmit.textContent='Sign Up';
88
+ });
89
+ authForm.addEventListener('submit', async (e)=>{
90
+ e.preventDefault();
91
+ const u = authUsername.value.trim(), p = authPassword.value, d = authDisplayName.value.trim();
92
+ if(!u||!p){ authError.textContent='Fill all fields.'; return; }
93
+ try{
94
+ const url = isLogin ? `${API}/login?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}`
95
+ : `${API}/signup?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}&display_name=${encodeURIComponent(d)}`;
96
+ const res = await fetch(url, {method:'POST'});
97
+ if(!res.ok) throw new Error((await res.json()).detail||'Auth failed');
98
+ const data = await res.json();
99
+ token = data.token;
100
+ currentUser = { id: data.id, username: data.username, display_name: data.display_name, avatar_path: data.avatar_path };
101
+ localStorage.setItem('token', token);
102
+ showChat();
103
+ }catch(err){ authError.textContent=err.message; }
104
+ });
105
+
106
+ async function autoLogin() {
107
+ if(!token) return false;
108
+ try{
109
+ const res = await fetch(`${API}/me`, {headers:{token}});
110
+ if(!res.ok){ token=null; localStorage.removeItem('token'); return false; }
111
+ currentUser = await res.json();
112
+ return true;
113
+ }catch{ return false; }
114
+ }
115
+
116
+ function showChat() {
117
+ authScreen.classList.add('hidden');
118
+ chatScreen.classList.remove('hidden');
119
+ connectWS();
120
+ loadMessages();
121
+ }
122
+
123
+ // --- WebSocket ---
124
+ function connectWS() {
125
+ if(ws && ws.readyState===WebSocket.OPEN) return;
126
+ const proto = location.protocol==='https:'?'wss:':'ws:';
127
+ ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`);
128
+ ws.onopen = ()=>{ console.log('WS open'); };
129
+ ws.onmessage = (ev)=> handleWSMessage(JSON.parse(ev.data));
130
+ ws.onclose = ()=> setTimeout(connectWS, 3000);
131
+ ws.onerror = ()=> {};
132
+ }
133
+
134
+ function handleWSMessage(msg) {
135
+ switch(msg.type){
136
+ case 'online_users': onlineCountSpan.textContent = `(${msg.users.length} online)`; break;
137
+ case 'user_online': break;
138
+ case 'user_offline': break;
139
+ case 'new_message':
140
+ if(messages[msg.id]) return;
141
+ messages[msg.id] = msg;
142
+ messageIds.push(msg.id);
143
+ renderMessage(msg);
144
+ scrollBottom();
145
+ if(document.visibilityState==='visible') ws.send(JSON.stringify({type:'mark_read', up_to_message_id:msg.id}));
146
+ break;
147
+ case 'messages_batch':
148
+ if(msg.messages.length>0){
149
+ for(const m of msg.messages){
150
+ if(!messages[m.id]){ messages[m.id]=m; messageIds.unshift(m.id); }
151
+ }
152
+ renderAll();
153
+ scrollBottom();
154
+ }
155
+ if(msg.messages.length<50) loadOlderBtn.classList.add('hidden');
156
+ else loadOlderBtn.classList.remove('hidden');
157
+ break;
158
+ case 'typing_update':
159
+ typingIndicator.textContent = msg.is_typing ? `${msg.username} is typing…` : ''; break;
160
+ case 'edit_message':
161
+ if(messages[msg.message_id]){
162
+ messages[msg.message_id].content = msg.content;
163
+ messages[msg.message_id].is_edited = true;
164
+ updateBubble(msg.message_id);
165
+ }
166
+ break;
167
+ case 'delete_message':
168
+ if(messages[msg.message_id]){
169
+ messages[msg.message_id].is_deleted = true;
170
+ updateBubble(msg.message_id);
171
+ }
172
+ break;
173
+ case 'read_receipt':
174
+ if(messages[msg.message_id] && messages[msg.message_id].sender_id === currentUser.id){
175
+ // Update status to "read"
176
+ messages[msg.message_id].status = 'read';
177
+ updateBubble(msg.message_id);
178
+ }
179
+ break;
180
+ }
181
+ }
182
+
183
+ // --- Render ---
184
+ function renderMessage(msg) {
185
+ const el = document.getElementById(`msg-${msg.id}`);
186
+ if(el){
187
+ updateBubble(msg.id);
188
+ return;
189
+ }
190
+ const div = document.createElement('div');
191
+ div.id = `msg-${msg.id}`;
192
+ div.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`;
193
+ div.innerHTML = buildHTML(msg);
194
+ messagesList.appendChild(div);
195
+ }
196
+ function buildHTML(msg) {
197
+ if(msg.is_deleted) return `<em>This message was deleted</em>`;
198
+ let html = '';
199
+ if(msg.reply_to_id && messages[msg.reply_to_id]){
200
+ const reply = messages[msg.reply_to_id];
201
+ html += `<div class="reply-indicator">↩ ${reply.display_name||reply.username}: ${truncate(reply.content,40)}</div>`;
202
+ }
203
+ html += `<div class="message-info"><span>${msg.display_name||msg.username}</span><span class="timestamp">${formatTime(msg.timestamp_ms)}</span></div>`;
204
+ html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
205
+ if(msg.is_edited) html += ` <em class="edited-label">[edited]</em>`;
206
+ if(msg.file_path) html += renderAttachment(msg);
207
+ if(msg.sender_id===currentUser.id && !msg.is_deleted){
208
+ const statusIcon = msg.status==='read' ? '✓✓' : '✓';
209
+ html += `<div class="read-status double-check">${statusIcon}</div>`;
210
+ html += `<div class="message-actions">
211
+ <button onclick="window.editMsg(${msg.id})">✏️</button>
212
+ <button onclick="window.deleteMsg(${msg.id})">🗑️</button>
213
+ <button onclick="window.replyTo(${msg.id})">↩</button>
214
+ </div>`;
215
+ }
216
+ return html;
217
+ }
218
+ function renderAttachment(msg) {
219
+ const url = `${API}/download/${msg.file_path}?token=${encodeURIComponent(token)}`;
220
+ if(msg.file_type.startsWith('image/')) return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" onerror="this.parentElement.innerHTML='Failed to decode'"></div>`;
221
+ if(msg.file_type.startsWith('video/')) return `<div class="attachment"><video controls><source src="${url}" type="${msg.file_type}"></video></div>`;
222
+ if(msg.file_type.startsWith('audio/')) return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
223
+ return `<div class="attachment"><div class="file-preview" onclick="window.open('${url}')">📄 ${escapeHtml(msg.file_name)}</div></div>`;
224
+ }
225
+ function updateBubble(id) {
226
+ const el = document.getElementById(`msg-${id}`);
227
+ if(!el) return;
228
+ const msg = messages[id];
229
+ el.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`;
230
+ if(msg.is_deleted) el.classList.add('deleted-bubble');
231
+ el.innerHTML = buildHTML(msg);
232
+ }
233
+ function renderAll() {
234
+ messagesList.innerHTML = '';
235
+ messageIds.forEach(id => { if(messages[id]) { renderMessage(messages[id]); } });
236
+ }
237
+ function scrollBottom() {
238
+ document.getElementById('messages-container').scrollTop = document.getElementById('messages-container').scrollHeight;
239
+ }
240
+
241
+ // --- Actions ---
242
+ window.replyTo = function(id){ replyToId=id; const m=messages[id]; if(m){ replyPreviewText.textContent=`Replying to ${m.display_name||m.username}: ${truncate(m.content,30)}`; replyPreview.classList.remove('hidden'); } };
243
+ cancelReply.addEventListener('click', ()=>{ replyToId=null; replyPreview.classList.add('hidden'); });
244
+ window.editMsg = function(id){
245
+ const newContent = prompt('Edit:', messages[id]?.content);
246
+ if(newContent!==null && newContent.trim()!=='') ws.send(JSON.stringify({type:'edit_message', message_id:id, content:newContent}));
247
+ };
248
+ window.deleteMsg = function(id){
249
+ if(confirm('Delete permanently?')) ws.send(JSON.stringify({type:'delete_message', message_id:id}));
250
+ };
251
+
252
+ // --- Sending ---
253
+ composeForm.addEventListener('submit', (e)=>{
254
+ e.preventDefault();
255
+ const content = messageInput.value.trim();
256
+ if(!content && !pendingFile) return;
257
+ const msg = { type:'chat_message', content, reply_to_id:replyToId, client_id:Date.now().toString(36)+Math.random().toString(36).slice(2) };
258
+ if(pendingFile){
259
+ msg.file_path = pendingFile.file_path;
260
+ msg.file_type = pendingFile.file_type;
261
+ msg.file_name = pendingFile.file_name;
262
+ pendingFile = null;
263
+ }
264
+ ws.send(JSON.stringify(msg));
265
+ messageInput.value = '';
266
+ clearReply();
267
+ });
268
+ function clearReply(){ replyToId=null; replyPreview.classList.add('hidden'); }
269
+
270
+ // -- Typing --
271
+ messageInput.addEventListener('input', ()=>{
272
+ ws.send(JSON.stringify({type:'typing', is_typing:true}));
273
+ clearTimeout(typingTimer);
274
+ typingTimer = setTimeout(()=> ws.send(JSON.stringify({type:'typing', is_typing:false})), 2000);
275
+ });
276
+
277
+ // -- File upload (chunked) --
278
+ attachBtn.addEventListener('click', ()=> fileInput.click());
279
+ fileInput.addEventListener('change', async (e)=>{
280
+ const file = e.target.files[0];
281
+ if(!file) return;
282
+ const CHUNK = 5*1024*1024;
283
+ const total = Math.ceil(file.size / CHUNK);
284
+ const uploadId = Date.now().toString(36)+Math.random().toString(36).slice(2);
285
+ uploadProgress.classList.remove('hidden');
286
+ uploadFilename.textContent = file.name;
287
+ uploadBar.value=0; uploadBar.max=100;
288
+ for(let i=0;i<total;i++){
289
+ const start = i*CHUNK, end = Math.min(start+CHUNK, file.size);
290
+ const blob = file.slice(start,end);
291
+ const fd = new FormData(); fd.append('file', blob);
292
+ const url = `${API}/upload_chunk?chunk_index=${i}&total_chunks=${total}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type||'application/octet-stream')}&upload_id=${uploadId}`;
293
+ try{
294
+ const res = await fetch(url, {method:'POST', body:fd, headers:{token}});
295
+ if(!res.ok) throw new Error('Upload failed');
296
+ const data = await res.json();
297
+ if(data.status==='complete'){
298
+ pendingFile = {file_path:data.file_path, file_type:data.file_type, file_name:data.file_name};
299
+ alert('File ready! Add a message and send.');
300
+ uploadProgress.classList.add('hidden');
301
+ break;
302
+ }
303
+ uploadBar.value = ((i+1)/total)*100;
304
+ }catch(err){
305
+ alert('Upload error: '+err.message);
306
+ uploadProgress.classList.add('hidden');
307
+ break;
308
+ }
309
+ }
310
+ fileInput.value='';
311
+ });
312
+
313
+ // -- Load older --
314
+ loadOlderBtn.addEventListener('click', ()=>{
315
+ if(ws) ws.send(JSON.stringify({type:'load_messages', before_id: oldestMessageId, limit:50}));
316
+ });
317
+
318
+ function loadMessages(){
319
+ if(ws && ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'load_messages', limit:50}));
320
+ }
321
+
322
+ // --- Settings ---
323
+ settingsBtn.addEventListener('click', ()=>{
324
+ settingDisplayName.value = currentUser?.display_name || '';
325
+ if(currentUser?.avatar_path){
326
+ avatarPreview.src = `${API}/download/${currentUser.avatar_path}?token=${token}`;
327
+ avatarPreview.style.display='block';
328
+ }else avatarPreview.style.display='none';
329
+ settingsModal.classList.remove('hidden');
330
+ });
331
+ closeSettingsBtn.addEventListener('click', ()=> settingsModal.classList.add('hidden'));
332
+ saveSettingsBtn.addEventListener('click', async ()=>{
333
+ const newName = settingDisplayName.value.trim();
334
+ if(newName !== currentUser.display_name){
335
+ await fetch(`${API}/update_profile?display_name=${encodeURIComponent(newName)}`, {method:'POST', headers:{token}});
336
+ currentUser.display_name = newName;
337
+ }
338
+ const avatarFile = settingAvatar.files[0];
339
+ if(avatarFile){
340
+ const fd = new FormData(); fd.append('file', avatarFile);
341
+ const res = await fetch(`${API}/upload_avatar`, {method:'POST', body:fd, headers:{token}});
342
+ if(res.ok){
343
+ const data = await res.json();
344
+ currentUser.avatar_path = data.avatar_path;
345
+ avatarPreview.src = `${API}/download/${data.avatar_path}?token=${token}`;
346
+ }
347
+ }
348
+ const theme = themeSelect.value;
349
+ if(theme==='theme-custom'){
350
+ localStorage.setItem('customSelfColor', customSelfColor.value);
351
+ localStorage.setItem('customOtherColor', customOtherColor.value);
352
+ localStorage.setItem('customTextColor', customTextColor.value);
353
+ }
354
+ applyTheme(theme);
355
+ settingsModal.classList.add('hidden');
356
+ });
357
+ themeSelect.addEventListener('change', (e)=>{
358
+ if(e.target.value==='theme-custom') customThemeSettings.classList.remove('hidden');
359
+ else customThemeSettings.classList.add('hidden');
360
+ });
361
+
362
+ logoutBtn.addEventListener('click', async ()=>{
363
+ if(ws) ws.close();
364
+ if(token) await fetch(`${API}/logout?token=${encodeURIComponent(token)}`, {method:'GET'});
365
+ localStorage.removeItem('token');
366
+ token=null; currentUser=null; messages={}; messageIds=[];
367
+ authScreen.classList.remove('hidden'); chatScreen.classList.add('hidden');
368
+ });
369
+
370
+ // --- Notification permission ---
371
+ if(window.Notification && Notification.permission==='default') Notification.requestPermission();
372
+
373
+ // --- Utils ---
374
+ function truncate(s,n){ return s.length>n ? s.slice(0,n-1)+'…' : s; }
375
+ function escapeHtml(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
376
+ function formatTime(ms){
377
+ const d = new Date(ms), now = new Date();
378
+ if(d.toDateString()===now.toDateString()) return `Today at ${d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;
379
+ return d.toLocaleDateString() +' '+ d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
380
+ }
381
+
382
+ // -- Init --
383
+ loadTheme();
384
+ (async ()=>{
385
+ if(token && await autoLogin()) showChat();
386
+ })();
387
+ })();