Spaces:
Running
Running
| // app.js | |
| (() => { | |
| const WS_URI = "wss://promptly-az40.onrender.com/ws"; | |
| const statusEl = document.getElementById("status"); | |
| const connectBtn = document.getElementById("connectBtn"); | |
| const disconnectBtn = document.getElementById("disconnectBtn"); | |
| const modeText = document.getElementById("modeText"); | |
| const messagesEl = document.getElementById("messages"); | |
| const inputEl = document.getElementById("input"); | |
| const sendBtn = document.getElementById("sendBtn"); | |
| const fileBtn = document.getElementById("fileBtn"); | |
| const filePicker = document.getElementById("filePicker"); | |
| const loginOverlay = document.getElementById("loginOverlay"); | |
| const loginForm = document.getElementById("loginForm"); | |
| const loginUsername = document.getElementById("loginUsername"); | |
| const loginPassword = document.getElementById("loginPassword"); | |
| const loginGuest = document.getElementById("loginGuest"); | |
| const themeToggle = document.getElementById("themeToggle"); | |
| const root = document.getElementById("root"); | |
| const onlineCountEl = document.getElementById("onlineCount"); | |
| let ws = null; | |
| let handshakeStep = 0; | |
| let awaiting = null; | |
| let lastUsername = ""; | |
| let connected = false; | |
| let pendingLogin = null; // {username,password} | |
| // Utility | |
| function stripAnsi(str){ return String(str).replace(/\x1b\[[0-9;]*m/g,''); } | |
| function setStatus(connectedNow){ | |
| connected = !!connectedNow; | |
| if(connected){ | |
| statusEl.classList.remove("disconnected"); | |
| statusEl.classList.add("connected"); | |
| statusEl.textContent = "Connected"; | |
| disconnectBtn.disabled = false; | |
| connectBtn.disabled = true; | |
| }else{ | |
| statusEl.classList.remove("connected"); | |
| statusEl.classList.add("disconnected"); | |
| statusEl.textContent = "Disconnected"; | |
| disconnectBtn.disabled = true; | |
| connectBtn.disabled = false; | |
| } | |
| } | |
| function appendMessage(content, cls='system'){ | |
| const el = document.createElement('div'); | |
| el.className = 'msg ' + (cls === 'me' ? 'me' : (cls === 'file' ? 'file' : 'system')); | |
| if(typeof content === 'string'){ | |
| el.textContent = content; | |
| } else if(content.type === 'file'){ | |
| // content: {filename, blob, isImage} | |
| const meta = document.createElement('div'); | |
| meta.className = 'meta'; | |
| meta.textContent = `π₯ Received ${content.filename}`; | |
| el.appendChild(meta); | |
| if(content.isImage){ | |
| const wrap = document.createElement('a'); | |
| wrap.href = URL.createObjectURL(content.blob); | |
| wrap.download = content.filename; | |
| wrap.className = 'img-wrap'; | |
| const img = document.createElement('img'); | |
| img.src = wrap.href; | |
| img.alt = content.filename; | |
| wrap.appendChild(img); | |
| el.appendChild(wrap); | |
| // download link below | |
| const dl = document.createElement('a'); | |
| dl.href = wrap.href; | |
| dl.download = content.filename; | |
| dl.className = 'download-link muted'; | |
| dl.textContent = `Download ${content.filename}`; | |
| el.appendChild(dl); | |
| // revoke after a bit when user navigates away | |
| setTimeout(()=>URL.revokeObjectURL(wrap.href), 60000); | |
| } else { | |
| const dl = document.createElement('a'); | |
| dl.href = URL.createObjectURL(content.blob); | |
| dl.download = content.filename; | |
| dl.className = 'download-link muted'; | |
| dl.textContent = `Download ${content.filename}`; | |
| el.appendChild(dl); | |
| setTimeout(()=>URL.revokeObjectURL(dl.href), 60000); | |
| } | |
| } else { | |
| el.textContent = JSON.stringify(content); | |
| } | |
| messagesEl.appendChild(el); | |
| messagesEl.scrollTo({top: messagesEl.scrollHeight, behavior: 'smooth'}); | |
| } | |
| function connect(){ | |
| if(ws) try{ ws.close() }catch(e){} | |
| ws = new WebSocket(WS_URI); | |
| ws.binaryType = 'arraybuffer'; | |
| ws.addEventListener('open', ()=>{ | |
| setStatus(true); | |
| appendMessage('π’ Connected to ' + WS_URI); | |
| handshakeStep = 0; | |
| awaiting = null; | |
| modeText.textContent = 'Handshake'; | |
| // if pending login already provided, we will act when user submits login | |
| }); | |
| ws.addEventListener('message', (ev) => { | |
| let data = ev.data; | |
| if(data instanceof ArrayBuffer){ | |
| // binary from server: try to treat as file (unlikely); skip for now | |
| appendMessage('π Received binary data (unsupported preview)', 'system'); | |
| return; | |
| } else { | |
| data = stripAnsi(String(data)); | |
| } | |
| // Detect server-sent file marker: 'π₯ filename base64' | |
| if(data.startsWith('π₯')){ | |
| const m = data.match(/^π₯\s+(\S+)\s+([\s\S]+)$/); | |
| if(m){ | |
| const fname = m[1]; | |
| const b64 = m[2]; | |
| try{ | |
| const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); | |
| const blob = new Blob([bytes]); | |
| const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(fname); | |
| appendMessage({type:'file', filename: fname, blob, isImage}, 'file'); | |
| return; | |
| }catch(e){ | |
| appendMessage('β File parse error: ' + e.toString()); | |
| } | |
| } | |
| } | |
| appendMessage(data, 'system'); | |
| // handshake flow handling | |
| if(data.toLowerCase().includes('please enter password') || data.toLowerCase().includes('enter password')){ | |
| awaiting = 'password'; | |
| modeText.textContent = 'Entering password'; | |
| } else if(data.toLowerCase().includes('enter your username') || data.toLowerCase().includes('enter username')){ | |
| awaiting = 'username'; | |
| modeText.textContent = 'Entering username'; | |
| } else if(data.toLowerCase().includes('welcome') || data.toLowerCase().includes('joined the chat')){ | |
| awaiting = null; | |
| modeText.textContent = 'Chat'; | |
| } else if(data.toLowerCase().includes('online users')){ | |
| // try to parse count | |
| const m = data.match(/Online users\s*\(?(\d+)\)?/i); | |
| if(m) onlineCountEl.textContent = m[1]; | |
| } | |
| }); | |
| ws.addEventListener('close', ()=>{ | |
| appendMessage('π΄ Connection closed.'); | |
| setStatus(false); | |
| ws = null; | |
| }); | |
| ws.addEventListener('error', (e)=>{ | |
| appendMessage('β Connection error. Check server & console.'); | |
| setStatus(false); | |
| }); | |
| } | |
| function disconnect(){ | |
| if(ws) try{ ws.close(); }catch(e){} | |
| ws = null; | |
| setStatus(false); | |
| appendMessage('Disconnected by client.'); | |
| } | |
| // send helper | |
| function sendText(msg){ | |
| if(!ws || ws.readyState !== WebSocket.OPEN){ | |
| appendMessage('β Not connected.', 'system'); | |
| return; | |
| } | |
| ws.send(msg); | |
| appendMessage(`[You] ${msg}`, 'me'); | |
| } | |
| // file upload: read file -> base64 -> send as /send filename base64 (server expects same) | |
| filePicker.addEventListener('change', async (e)=>{ | |
| const f = e.target.files && e.target.files[0]; | |
| if(!f) return; | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| try{ | |
| const ab = reader.result; | |
| const bytes = new Uint8Array(ab); | |
| let binary = ''; | |
| const chunk = 0x8000; | |
| for(let i=0;i<bytes.length;i+=chunk){ | |
| const sub = bytes.subarray(i, Math.min(i+chunk, bytes.length)); | |
| binary += String.fromCharCode.apply(null, sub); | |
| } | |
| const b64 = btoa(binary); | |
| const payload = `/send ${f.name} ${b64}`; | |
| if(ws && ws.readyState === WebSocket.OPEN){ | |
| ws.send(payload); | |
| appendMessage(`[file] ${f.name}`, 'me'); | |
| } else appendMessage('β Not connected.', 'system'); | |
| }catch(err){ | |
| appendMessage('β File read error: ' + err.toString(), 'system'); | |
| } | |
| }; | |
| reader.onerror = ()=> appendMessage('β Could not read file.', 'system'); | |
| reader.readAsArrayBuffer(f); | |
| }); | |
| fileBtn.addEventListener('click', ()=> filePicker.click()); | |
| // login handling: send password then username preserving handshake order | |
| loginForm.addEventListener('submit', (ev)=>{ | |
| ev.preventDefault(); | |
| const username = loginUsername.value.trim(); | |
| const password = loginPassword.value; | |
| pendingLogin = {username, password}; | |
| // ensure connected | |
| if(!ws || ws.readyState !== WebSocket.OPEN){ | |
| appendMessage('Connecting to server...', 'system'); | |
| connect(); | |
| // wait until open | |
| const waitOpen = () => new Promise(res=>{ | |
| if(ws && ws.readyState === WebSocket.OPEN) return res(); | |
| const t = setInterval(()=>{ if(ws && ws.readyState === WebSocket.OPEN){ clearInterval(t); res(); } }, 150); | |
| // timeout fallback | |
| setTimeout(()=>res(), 5000); | |
| }); | |
| waitOpen().then(()=> doHandshakeLogin(pendingLogin)); | |
| } else doHandshakeLogin(pendingLogin); | |
| }); | |
| loginGuest.addEventListener('click', ()=>{ | |
| loginUsername.value = 'Guest' + Math.floor(Math.random()*9000+1000); | |
| loginPassword.value = ''; | |
| loginForm.dispatchEvent(new Event('submit', {cancelable:true})); | |
| }); | |
| function doHandshakeLogin({username,password}){ | |
| // send password then username with slight delay to emulate terminal client handshake | |
| try{ | |
| if(password) ws.send(password); | |
| appendMessage('[You] (password entered)', 'me'); | |
| setTimeout(()=>{ | |
| ws.send(username); | |
| appendMessage('[You] ' + username, 'me'); | |
| // hide login overlay and switch to chat | |
| loginOverlay.style.display = 'none'; | |
| // keep username stored | |
| lastUsername = username; | |
| modeText.textContent = 'Chat'; | |
| inputEl.focus(); | |
| }, 300); | |
| }catch(e){ | |
| appendMessage('β Login send error: ' + e.toString(), 'system'); | |
| } | |
| } | |
| // message send | |
| sendBtn.addEventListener('click', ()=>{ | |
| const txt = inputEl.value.trim(); | |
| if(!txt) return; | |
| sendText(txt); | |
| inputEl.value = ''; | |
| }); | |
| inputEl.addEventListener('keydown', (e)=>{ | |
| if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); sendBtn.click(); } | |
| }); | |
| // connect/disconnect controls | |
| connectBtn.addEventListener('click', ()=> connect()); | |
| disconnectBtn.addEventListener('click', ()=> disconnect()); | |
| // theme toggle | |
| themeToggle.addEventListener('click', ()=>{ | |
| if(root.classList.contains('theme-dark')){ | |
| root.classList.remove('theme-dark'); root.classList.add('theme-light'); | |
| document.documentElement.style.setProperty('--bg-dark','#f5f7fb'); | |
| document.documentElement.style.setProperty('--text','#071029'); | |
| themeToggle.textContent = 'π'; | |
| } else { | |
| root.classList.remove('theme-light'); root.classList.add('theme-dark'); | |
| themeToggle.textContent = 'π'; | |
| document.documentElement.style.removeProperty('--bg-dark'); | |
| document.documentElement.style.removeProperty('--text'); | |
| } | |
| }); | |
| // auto-connect on load | |
| window.addEventListener('load', ()=>{ | |
| connect(); | |
| // show login overlay (auto) | |
| loginOverlay.style.display = 'flex'; | |
| loginUsername.focus(); | |
| // register sw if available | |
| if('serviceWorker' in navigator){ | |
| navigator.serviceWorker.register('service-worker.js').catch(()=>{/*ignore*/}); | |
| } | |
| }); | |
| // expose for debugging | |
| window._promptly = {connect,disconnect, wsRef: ()=> ws}; | |
| })(); |