pages.js (14775B)
1 const { VERSION } = require('../config'); 2 3 // --- SHARED LAYOUT --- 4 // One small design system so every page (login, status, logs) shares the 5 // same responsive behavior instead of hand-rolled inline styles per page. 6 const BASE_STYLES = ` 7 :root { 8 --accent: #25D366; 9 --accent-dark: #1da851; 10 --bg: #f0f2f5; 11 --card: #ffffff; 12 --text: #1c1e21; 13 --muted: #65676b; 14 --border: #e4e6eb; 15 --danger: #ff4444; 16 --radius: 12px; 17 } 18 * { box-sizing: border-box; } 19 body { 20 margin: 0; 21 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 22 background: var(--bg); 23 color: var(--text); 24 min-height: 100vh; 25 padding: 16px; 26 } 27 .page { 28 max-width: 480px; 29 margin: 0 auto; 30 display: flex; 31 flex-direction: column; 32 min-height: calc(100vh - 32px); 33 justify-content: center; 34 gap: 16px; 35 } 36 .card { 37 background: var(--card); 38 border-radius: var(--radius); 39 box-shadow: 0 2px 10px rgba(0,0,0,0.06); 40 padding: 28px 24px; 41 width: 100%; 42 } 43 .brand { 44 display: flex; 45 align-items: center; 46 justify-content: center; 47 gap: 8px; 48 margin-bottom: 4px; 49 } 50 .brand-mark { 51 width: 36px; 52 height: 36px; 53 flex-shrink: 0; 54 display: block; 55 } 56 .brand-mark img { width: 100%; height: 100%; display: block; } 57 .brand-text { text-align: left; } 58 .brand-text h1 { font-size: 17px; margin: 0; line-height: 1.2; } 59 .brand-text .version { font-size: 12px; color: var(--muted); } 60 .subtitle { 61 text-align: center; 62 color: var(--muted); 63 font-size: 13.5px; 64 margin: 0 0 20px; 65 } 66 label { display: block; margin-bottom: 6px; font-size: 13.5px; font-weight: 600; } 67 input[type="text"], input[type="password"] { 68 width: 100%; 69 padding: 11px 12px; 70 border: 1px solid var(--border); 71 border-radius: 8px; 72 font-size: 15px; 73 background: #fafafa; 74 } 75 input[type="text"]:focus, input[type="password"]:focus { 76 outline: none; 77 border-color: var(--accent); 78 background: white; 79 } 80 .field { margin-bottom: 14px; } 81 .checkbox-row { 82 display: flex; 83 align-items: center; 84 font-size: 13.5px; 85 color: var(--muted); 86 margin-bottom: 18px; 87 } 88 .checkbox-row input { margin-right: 8px; } 89 button, .btn { 90 width: 100%; 91 padding: 12px; 92 background: var(--accent); 93 color: white; 94 border: none; 95 border-radius: 8px; 96 font-weight: 600; 97 font-size: 15px; 98 cursor: pointer; 99 text-align: center; 100 text-decoration: none; 101 display: inline-block; 102 } 103 button:hover, .btn:hover { background: var(--accent-dark); } 104 .btn-secondary { 105 background: transparent; 106 color: var(--text); 107 border: 1px solid var(--border); 108 } 109 .btn-secondary:hover { background: #f5f5f5; } 110 .btn-danger { background: var(--danger); } 111 .error-text { color: var(--danger); font-size: 13px; margin-top: 10px; text-align: center; } 112 .topbar { 113 display: flex; 114 justify-content: space-between; 115 align-items: center; 116 max-width: 480px; 117 margin: 0 auto 4px; 118 width: 100%; 119 } 120 .topbar h1 { font-size: 16px; margin: 0; } 121 .status-badge { 122 display: inline-flex; 123 align-items: center; 124 gap: 6px; 125 font-size: 13px; 126 font-weight: 600; 127 padding: 4px 10px 4px 8px; 128 border-radius: 999px; 129 white-space: nowrap; 130 } 131 .status-badge .dot { width: 8px; height: 8px; border-radius: 50%; } 132 .status-connected { background: #e6f8ec; color: #1a7a3d; } 133 .status-connected .dot { background: #22c55e; } 134 .status-waiting { background: #fff6e0; color: #92650a; } 135 .status-waiting .dot { background: #f59e0b; } 136 .status-init { background: #eee; color: var(--muted); } 137 .status-init .dot { background: #9ca3af; } 138 .qr-wrap { text-align: center; margin: 6px 0 4px; } 139 .qr-wrap img { width: 100%; max-width: 260px; border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: white; } 140 .hint { color: var(--muted); font-size: 13px; text-align: center; margin: 8px 0 0; } 141 .section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0 0 10px; font-weight: 700; } 142 .link-row { 143 display: flex; 144 align-items: center; 145 justify-content: space-between; 146 padding: 12px 14px; 147 border: 1px solid var(--border); 148 border-radius: 8px; 149 text-decoration: none; 150 color: var(--text); 151 font-size: 14.5px; 152 font-weight: 600; 153 margin-bottom: 8px; 154 } 155 .link-row:hover { background: #fafafa; } 156 .link-row .arrow { color: var(--muted); font-weight: 400; } 157 .endpoint { 158 display: grid; 159 grid-template-columns: 52px 1fr; 160 gap: 10px; 161 padding: 10px 0; 162 border-bottom: 1px solid var(--border); 163 font-size: 13px; 164 } 165 .endpoint:last-child { border-bottom: none; } 166 .method { 167 font-weight: 700; 168 font-size: 11px; 169 padding: 3px 0; 170 border-radius: 5px; 171 text-align: center; 172 height: fit-content; 173 color: white; 174 } 175 .method-get { background: #3b82f6; } 176 .method-post { background: #8b5cf6; } 177 .endpoint-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; word-break: break-all; } 178 .endpoint-desc { color: var(--muted); font-size: 12.5px; margin-top: 2px; } 179 .endpoint-auth { display: inline-block; margin-top: 4px; font-size: 11px; color: var(--muted); background: #f2f3f5; padding: 1px 7px; border-radius: 4px; } 180 @media (max-width: 400px) { 181 .card { padding: 22px 18px; } 182 body { padding: 12px; } 183 } 184 `; 185 186 function layout({ title, refresh, content, wide }) { 187 return `<!DOCTYPE html> 188 <html lang="en"> 189 <head> 190 <meta charset="UTF-8"> 191 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> 192 <link rel="icon" href="/icon.svg" type="image/svg+xml"> 193 <title>${title}</title> 194 ${refresh ? `<meta http-equiv="refresh" content="${refresh}">` : ''} 195 <style>${BASE_STYLES}${wide ? ' .page { max-width: 560px; }' : ''}</style> 196 </head> 197 <body> 198 ${content} 199 </body> 200 </html>`; 201 } 202 203 const brand = ` 204 <div class="brand"> 205 <div class="brand-mark"><img src="/icon.svg" alt="WhatsApp Logger"></div> 206 <div class="brand-text"> 207 <h1>WhatsApp Logger</h1> 208 <div class="version">v${VERSION}</div> 209 </div> 210 </div> 211 `; 212 213 function logoutBtn() { 214 return `<a href="/logout" class="btn btn-secondary" style="width:auto; padding: 8px 16px; font-size: 13px;">Logout</a>`; 215 } 216 217 // --- LOGIN PAGE --- 218 function loginPage() { 219 const content = ` 220 <div class="page"> 221 <div class="card"> 222 ${brand} 223 <p class="subtitle">Sign in to view your archived chats</p> 224 <form action="/login" method="POST"> 225 <div class="field"> 226 <label>Username</label> 227 <input type="text" name="username" autocomplete="username" required> 228 </div> 229 <div class="field"> 230 <label>Password</label> 231 <input type="password" name="password" autocomplete="current-password" required> 232 </div> 233 <label class="checkbox-row"> 234 <input type="checkbox" name="remember" value="yes"> 235 Keep me logged in for 5 mins 236 </label> 237 <button type="submit">Log in</button> 238 </form> 239 </div> 240 <p class="hint">Self-hosted · your data never leaves your own Firebase project</p> 241 </div> 242 `; 243 return layout({ title: 'Login · WhatsApp Logger', content }); 244 } 245 246 function loginFailedPage() { 247 const content = ` 248 <div class="page"> 249 <div class="card" style="text-align: center;"> 250 ${brand} 251 <p class="error-text" style="margin-bottom: 18px;">Invalid username or password.</p> 252 <a class="btn" href="/login">Try again</a> 253 </div> 254 </div> 255 `; 256 return layout({ title: 'Login failed · WhatsApp Logger', content }); 257 } 258 259 // --- API DOCS (shown on the status page) --- 260 const ENDPOINTS = [ 261 { method: 'POST', path: '/api/verify', desc: 'Exchange username/password for a bearer token.', auth: 'none' }, 262 { method: 'GET', path: '/api/chats/stream', desc: 'Server-sent events stream of chat list updates.', auth: 'token' }, 263 { method: 'GET', path: '/api/messages/stream', desc: 'Server-sent events stream of messages for a chat (?chatId=&since=).', auth: 'token' }, 264 { method: 'POST', path: '/api/rename', desc: 'Set a custom name for a chat. Body: { id, customName }.', auth: 'token' }, 265 { method: 'GET', path: '/api/export', desc: 'Dump all cached chats and messages as JSON.', auth: 'token' }, 266 { method: 'GET', path: '/logs', desc: 'View server logs. Add ?format=json for raw JSON.', auth: 'token/cookie' }, 267 { method: 'GET', path: '/ping', desc: 'Health check. Always returns 200.', auth: 'none' } 268 ]; 269 270 function apiDocsSection() { 271 const rows = ENDPOINTS.map(e => ` 272 <div class="endpoint"> 273 <span class="method method-${e.method.toLowerCase()}">${e.method}</span> 274 <div> 275 <div class="endpoint-path">${e.path}</div> 276 <div class="endpoint-desc">${e.desc}</div> 277 <span class="endpoint-auth">auth: ${e.auth}</span> 278 </div> 279 </div> 280 `).join(''); 281 282 return ` 283 <div class="card"> 284 <p class="section-title">API Endpoints</p> 285 ${rows} 286 </div> 287 `; 288 } 289 290 // --- STATUS PAGE (unified: connected / waiting for QR scan / initializing) --- 291 function statusBody({ badgeHtml, innerContent }) { 292 return ` 293 <div class="page"> 294 <div class="topbar"> 295 <h1>WhatsApp Logger</h1> 296 ${logoutBtn()} 297 </div> 298 <div class="card"> 299 ${badgeHtml} 300 ${innerContent} 301 </div> 302 <div class="card"> 303 <p class="section-title">Quick links</p> 304 <a class="link-row" href="/logs"> 305 <span>View server logs</span> 306 <span class="arrow">→</span> 307 </a> 308 </div> 309 ${apiDocsSection()} 310 </div> 311 `; 312 } 313 314 function connectedPage() { 315 const badgeHtml = `<span class="status-badge status-connected"><span class="dot"></span>Connected</span>`; 316 const innerContent = ` 317 <p style="margin: 14px 0 0; color: var(--muted); font-size: 14px;"> 318 Linked to WhatsApp and syncing to Firestore in real time. 319 </p> 320 `; 321 return layout({ 322 title: 'Status · WhatsApp Logger', 323 content: statusBody({ badgeHtml, innerContent }) 324 }); 325 } 326 327 function qrPage(qrImage) { 328 const badgeHtml = `<span class="status-badge status-waiting"><span class="dot"></span>Waiting for scan</span>`; 329 const innerContent = ` 330 <div class="qr-wrap"> 331 <img src="${qrImage}" alt="QR Code"> 332 </div> 333 <p class="hint">Open WhatsApp → Linked Devices → Link a Device, then scan this code. Refreshes every 5 seconds.</p> 334 `; 335 return layout({ 336 title: 'Scan to link · WhatsApp Logger', 337 refresh: 5, 338 content: statusBody({ badgeHtml, innerContent }) 339 }); 340 } 341 342 function qrErrorPage() { 343 const content = ` 344 <div class="page"> 345 <div class="card" style="text-align: center;"> 346 ${brand} 347 <p class="error-text">Couldn't generate the QR code. Refresh to try again.</p> 348 </div> 349 </div> 350 `; 351 return layout({ title: 'Error · WhatsApp Logger', content }); 352 } 353 354 function initializingPage() { 355 const badgeHtml = `<span class="status-badge status-init"><span class="dot"></span>Initializing</span>`; 356 const innerContent = ` 357 <p style="margin: 14px 0 0; color: var(--muted); font-size: 14px;"> 358 Connecting to WhatsApp and restoring the saved session. This page refreshes automatically. 359 </p> 360 `; 361 return layout({ 362 title: 'Initializing · WhatsApp Logger', 363 refresh: 2, 364 content: statusBody({ badgeHtml, innerContent }) 365 }); 366 } 367 368 // --- LOGS PAGE --- 369 function logsPage(logBuffer) { 370 const logLines = logBuffer.map(l => { 371 const time = new Date(l.timestamp).toLocaleTimeString(); 372 const color = l.level === 'error' ? '#ff6b6b' : '#a9dc76'; 373 return `<div style="color: ${color}">[${time}] [${l.level.toUpperCase()}] ${l.message}</div>`; 374 }).join(''); 375 376 const content = ` 377 <div style="max-width: 100%;"> 378 <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;"> 379 <h2 style="color: #fff; margin: 0; font-size: 18px;">System Logs</h2> 380 <a href="/" style="color: #a9dc76; font-size: 13px; text-decoration: none;">← Back to status</a> 381 </div> 382 <div class="log-container"> 383 ${logLines.length > 0 ? logLines : '<div>No logs yet...</div>'} 384 </div> 385 </div> 386 `; 387 388 return `<!DOCTYPE html> 389 <html lang="en"> 390 <head> 391 <meta charset="UTF-8"> 392 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> 393 <link rel="icon" href="/icon.svg" type="image/svg+xml"> 394 <meta http-equiv="refresh" content="5"> 395 <title>System Logs</title> 396 <style> 397 * { box-sizing: border-box; } 398 body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #1e1e1e; color: #d4d4d4; padding: 16px; margin: 0; } 399 .log-container { background: #000; padding: 14px; border-radius: 8px; overflow-x: auto; max-width: 100%; white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.6; } 400 @media (max-width: 400px) { body { padding: 10px; } .log-container { padding: 10px; font-size: 12px; } } 401 </style> 402 </head> 403 <body onload="window.scrollTo(0,document.body.scrollHeight);"> 404 ${content} 405 </body> 406 </html>`; 407 } 408 409 module.exports = { 410 logsPage, 411 loginPage, 412 loginFailedPage, 413 connectedPage, 414 qrPage, 415 qrErrorPage, 416 initializingPage 417 };