index.html
1 <!DOCTYPE html> 2 <html lang="en"> 3 4 <head> 5 <meta charset="utf-8" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 <link rel="icon" type="image/svg+xml" href="/symbol.svg" /> 8 <title>Ceratina Captive Portal</title> 9 <style> 10 :root { 11 --background: oklch(0.14 0.01 40); 12 --foreground: oklch(0.84 0.12 82); 13 --card: oklch(0.18 0.02 40); 14 --primary: oklch(0.76 0.15 80); 15 --primary-foreground: oklch(0.18 0.02 40); 16 --secondary: oklch(0.16 0.012 36); 17 --muted-foreground: oklch(0.66 0.07 78); 18 --accent: oklch(0.3 0.06 72); 19 --destructive: oklch(0.62 0.22 27); 20 --border: oklch(0.36 0.05 70 / 0.6); 21 --radius: 14px; 22 --success: oklch(0.65 0.17 145); 23 --shell-glow: 0 0 0 1px oklch(0.42 0.06 72 / 0.2), 0 18px 60px oklch(0.28 0.07 74 / 0.18); 24 --row-highlight: oklch(0.22 0.04 145 / 0.35); 25 } 26 27 * { box-sizing: border-box; border-color: var(--border); } 28 html { color-scheme: dark; } 29 30 body { 31 margin: 0; 32 font: 15px/1.4 system-ui, sans-serif; 33 background: var(--background); 34 color: var(--foreground); 35 min-height: 100vh; 36 -webkit-font-smoothing: antialiased; 37 background-image: 38 radial-gradient(circle at top, color-mix(in oklab, var(--primary) 14%, transparent) 0%, transparent 28%), 39 radial-gradient(circle at left 20%, color-mix(in oklab, var(--accent) 10%, transparent) 0%, transparent 22%); 40 } 41 42 ::selection { 43 background: color-mix(in oklab, var(--primary) 32%, transparent); 44 color: var(--foreground); 45 } 46 47 main { 48 max-width: 800px; 49 margin: 0 auto; 50 padding: 16px; 51 } 52 53 .panel { 54 background: oklch(0.18 0.02 40 / 0.92); 55 border: 1px solid var(--border); 56 border-radius: var(--radius); 57 backdrop-filter: blur(4px); 58 -webkit-backdrop-filter: blur(4px); 59 box-shadow: var(--shell-glow); 60 overflow: hidden; 61 } 62 63 /* ── Header ── */ 64 65 .header { 66 display: flex; 67 align-items: center; 68 gap: 10px; 69 padding: 14px 16px; 70 border-bottom: 1px solid var(--border); 71 flex-wrap: wrap; 72 } 73 74 .header h1 { 75 margin: 0; 76 font-size: 18px; 77 font-weight: 700; 78 color: var(--foreground); 79 } 80 81 .badge { 82 display: inline-flex; 83 align-items: center; 84 gap: 6px; 85 padding: 4px 12px; 86 border-radius: 999px; 87 border: 1px solid var(--border); 88 background: var(--secondary); 89 font-size: 13px; 90 color: var(--muted-foreground); 91 white-space: nowrap; 92 } 93 94 .badge .dot { 95 width: 8px; 96 height: 8px; 97 border-radius: 50%; 98 background: var(--success); 99 flex-shrink: 0; 100 } 101 102 .badge.ip { color: var(--success); } 103 104 /* ── Status bar ── */ 105 106 .status-bar { 107 padding: 10px 16px; 108 border-bottom: 1px solid var(--border); 109 font-size: 14px; 110 color: var(--muted-foreground); 111 text-align: center; 112 } 113 114 .status-bar.ok { color: oklch(0.78 0.12 150); } 115 .status-bar.err { color: oklch(0.75 0.14 25); } 116 117 /* ── Connect form ── */ 118 119 .connect-row { 120 display: grid; 121 grid-template-columns: 1fr 1fr auto; 122 gap: 10px; 123 padding: 12px 16px; 124 border-bottom: 1px solid var(--border); 125 align-items: center; 126 } 127 128 .connect-row input { 129 width: 100%; 130 padding: 9px 12px; 131 border-radius: 8px; 132 border: 1px solid var(--border); 133 background: oklch(0.14 0.01 40 / 0.7); 134 color: var(--foreground); 135 font: inherit; 136 outline: none; 137 transition: border-color 0.3s ease; 138 } 139 140 .connect-row input::placeholder { color: var(--muted-foreground); } 141 .connect-row input:hover { border-color: oklch(0.76 0.15 80 / 0.45); } 142 .connect-row input:focus { 143 border-color: oklch(0.76 0.15 80 / 0.6); 144 box-shadow: 0 0 0 2px oklch(0.76 0.15 80 / 0.2); 145 } 146 147 .btn-connect { 148 display: inline-flex; 149 align-items: center; 150 gap: 6px; 151 padding: 9px 18px; 152 border: 1px solid var(--border); 153 border-radius: 8px; 154 background: oklch(0.14 0.01 40 / 0.7); 155 color: var(--foreground); 156 font: inherit; 157 cursor: pointer; 158 white-space: nowrap; 159 transition: all 0.3s ease; 160 } 161 162 .btn-connect:hover { 163 border-color: oklch(0.76 0.15 80 / 0.5); 164 background: oklch(0.3 0.06 72 / 0.45); 165 } 166 167 .btn-connect:disabled { opacity: 0.6; cursor: default; } 168 169 /* ── Network table ── */ 170 171 .net-table { 172 width: 100%; 173 border-collapse: collapse; 174 font-size: 14px; 175 } 176 177 .net-table thead th { 178 text-align: left; 179 padding: 10px 16px; 180 font-size: 11px; 181 font-weight: 600; 182 text-transform: uppercase; 183 letter-spacing: 0.05em; 184 color: var(--muted-foreground); 185 border-bottom: 1px solid var(--border); 186 } 187 188 .net-table tbody td { 189 padding: 12px 16px; 190 border-bottom: 1px solid oklch(0.36 0.05 70 / 0.25); 191 vertical-align: middle; 192 } 193 194 .net-table tbody tr:last-child td { border-bottom: none; } 195 196 .net-table tbody tr:hover { 197 background: oklch(0.3 0.06 72 / 0.15); 198 } 199 200 .net-table tbody tr.connected { 201 background: var(--row-highlight); 202 } 203 204 .ssid-link { 205 color: var(--primary); 206 text-decoration: underline; 207 text-underline-offset: 3px; 208 cursor: pointer; 209 font-weight: 500; 210 } 211 212 .ssid-link:hover { color: oklch(0.82 0.13 82); } 213 214 .ssid-cell { 215 display: flex; 216 align-items: center; 217 gap: 8px; 218 flex-wrap: wrap; 219 } 220 221 .empty-row td { 222 text-align: center; 223 color: var(--muted-foreground); 224 padding: 20px 16px; 225 } 226 227 /* ── Responsive ── */ 228 229 @media (max-width: 560px) { 230 .connect-row { 231 grid-template-columns: 1fr; 232 } 233 234 .net-table { font-size: 13px; } 235 .net-table thead th, .net-table tbody td { padding: 10px 12px; } 236 .header { gap: 8px; } 237 } 238 </style> 239 </head> 240 241 <body> 242 <main> 243 <div class="panel"> 244 <div class="header"> 245 <h1>Network</h1> 246 <div style="flex:1"></div> 247 <span class="badge" id="apBadge">--</span> 248 <span class="badge" id="apIpBadge">--</span> 249 <span class="badge" id="uptimeBadge">--</span> 250 </div> 251 252 <form id="connectForm" class="connect-row"> 253 <input id="ssidInput" name="ssid" type="text" placeholder="SSID" autocomplete="username" /> 254 <input id="passwordInput" name="password" type="password" 255 placeholder="Password (blank for open)" autocomplete="current-password" /> 256 <button id="connectButton" class="btn-connect" type="submit">📶 Connect</button> 257 </form> 258 259 <div class="status-bar" id="statusBar"></div> 260 261 <table class="net-table"> 262 <thead> 263 <tr> 264 <th id="ssidHeader">SSID</th> 265 <th>RSSI</th> 266 <th>Channel</th> 267 <th>Security</th> 268 </tr> 269 </thead> 270 <tbody id="networkBody"> 271 <tr class="empty-row"><td colspan="4">No scan results yet.</td></tr> 272 </tbody> 273 </table> 274 </div> 275 </main> 276 277 <script> 278 const ssidInput = document.getElementById("ssidInput"); 279 const passwordInput = document.getElementById("passwordInput"); 280 const connectButton = document.getElementById("connectButton"); 281 const connectForm = document.getElementById("connectForm"); 282 const statusBar = document.getElementById("statusBar"); 283 const networkBody = document.getElementById("networkBody"); 284 const apBadge = document.getElementById("apBadge"); 285 const apIpBadge = document.getElementById("apIpBadge"); 286 const uptimeBadge = document.getElementById("uptimeBadge"); 287 const ssidHeader = document.getElementById("ssidHeader"); 288 289 let connectedSsid = ""; 290 let connectedIp = ""; 291 292 const text = (v, fb = "--") => { 293 if (v === null || v === undefined) return fb; 294 const s = String(v).trim(); 295 return s || fb; 296 }; 297 298 const uptime = (sec) => { 299 const t = Math.max(0, Number(sec) || 0); 300 const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = Math.floor(t % 60); 301 if (h > 0) return `${h}h ${m}m`; 302 if (m > 0) return `${m}m ${s}s`; 303 return `${s}s`; 304 }; 305 306 const setStatus = (msg, type = "") => { 307 statusBar.textContent = msg; 308 statusBar.className = "status-bar" + (type === "ok" ? " ok" : type === "err" ? " err" : ""); 309 statusBar.style.display = msg ? "" : "none"; 310 }; 311 312 const renderNetworks = (networks) => { 313 networkBody.innerHTML = ""; 314 315 if (!Array.isArray(networks) || networks.length === 0) { 316 networkBody.innerHTML = '<tr class="empty-row"><td colspan="4">No networks found.</td></tr>'; 317 return; 318 } 319 320 networks.forEach((net) => { 321 const tr = document.createElement("tr"); 322 const name = text(net.ssid, "(hidden)"); 323 const isConnected = connectedSsid && net.ssid === connectedSsid; 324 325 if (isConnected) tr.classList.add("connected"); 326 327 const security = net.open ? "open" : text(net.encryption, "secured"); 328 const ipBadge = isConnected && connectedIp 329 ? `<span class="badge ip"><span class="dot"></span>${connectedIp}</span>` 330 : ""; 331 332 tr.innerHTML = ` 333 <td><div class="ssid-cell"><span class="ssid-link">${name}</span>${ipBadge}</div></td> 334 <td>${typeof net.rssi === "number" ? net.rssi : "--"}</td> 335 <td>${text(net.channel, "--")}</td> 336 <td>${security}</td> 337 `; 338 339 tr.querySelector(".ssid-link").addEventListener("click", () => { 340 ssidInput.value = net.ssid || ""; 341 if (net.open) passwordInput.value = ""; 342 ssidInput.focus(); 343 }); 344 345 networkBody.appendChild(tr); 346 }); 347 }; 348 349 const refreshDeviceStatus = async () => { 350 try { 351 const [wRes, dRes] = await Promise.all([ 352 fetch("/api/wireless/status", { cache: "no-store" }), 353 fetch("/api/system/device/status", { cache: "no-store" }) 354 ]); 355 356 if (!wRes.ok || !dRes.ok) throw new Error(); 357 358 const w = (await wRes.json()).data || {}; 359 const d = (await dRes.json()).data || {}; 360 const device = d.device || {}; 361 const runtime = d.runtime || {}; 362 363 apBadge.textContent = w.ap_active ? text(w.ap_ssid, "AP") : "AP inactive"; 364 apIpBadge.textContent = text(w.ap_ipv4, "192.168.4.1"); 365 uptimeBadge.textContent = "\u23F1 " + uptime(runtime.uptime_seconds); 366 367 connectedSsid = w.connected ? (w.sta_ssid || "") : ""; 368 connectedIp = w.connected ? (w.sta_ipv4 || "") : ""; 369 } catch { 370 apBadge.textContent = "--"; 371 apIpBadge.textContent = "--"; 372 } 373 }; 374 375 const scanNetworks = async () => { 376 connectButton.disabled = true; 377 setStatus("Scanning\u2026"); 378 379 try { 380 const res = await fetch("/api/wireless/actions/scan", { 381 method: "POST", 382 headers: { "Content-Type": "application/json" }, 383 body: "{}" 384 }); 385 386 if (!res.ok) throw new Error(`HTTP ${res.status}`); 387 388 const networks = (await res.json()).data?.networks || []; 389 renderNetworks(networks); 390 ssidHeader.textContent = networks.length ? `SSID (${networks.length})` : "SSID"; 391 setStatus(""); 392 } catch { 393 renderNetworks([]); 394 setStatus("Scan failed.", "err"); 395 } finally { 396 connectButton.disabled = false; 397 } 398 }; 399 400 connectForm.addEventListener("submit", async (e) => { 401 e.preventDefault(); 402 const ssid = ssidInput.value.trim(); 403 if (!ssid) { setStatus("Enter an SSID first.", "err"); ssidInput.focus(); return; } 404 405 connectButton.disabled = true; 406 setStatus(`Connecting to ${ssid}\u2026`); 407 408 try { 409 const res = await fetch("/api/wireless/actions/connect", { 410 method: "POST", 411 headers: { "Content-Type": "application/json" }, 412 body: JSON.stringify({ ssid, password: passwordInput.value }) 413 }); 414 415 const payload = await res.json(); 416 if (!res.ok) throw new Error(payload.error?.message || `HTTP ${res.status}`); 417 418 await refreshDeviceStatus(); 419 420 if (payload.ok) { 421 const ip = payload.data?.sta_ipv4 ? ` \u2014 ${payload.data.sta_ipv4}` : ""; 422 setStatus(`Connected to ${ssid}${ip}`, "ok"); 423 scanNetworks(); 424 } else { 425 setStatus("Connection failed. Check password.", "err"); 426 } 427 } catch (err) { 428 setStatus(`Failed: ${err.message}`, "err"); 429 } finally { 430 connectButton.disabled = false; 431 } 432 }); 433 434 refreshDeviceStatus(); 435 scanNetworks(); 436 setInterval(refreshDeviceStatus, 5000); 437 </script> 438 </body> 439 440 </html>