|
85 | 85 | const WIFI_ICON = |
86 | 86 | "M15.384 6.115a.485.485 0 0 0-.047-.736A12.44 12.44 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.518.518 0 0 0 .668.05A11.45 11.45 0 0 1 8 4c2.507 0 4.827.802 6.716 2.164.205.148.49.13.668-.049m-2.55 2.516a.482.482 0 0 0-.063-.745A8.46 8.46 0 0 0 8 7a8.46 8.46 0 0 0-4.77 1.886.482.482 0 0 0-.064.745.525.525 0 0 0 .654.065A7.46 7.46 0 0 1 8 8c1.71 0 3.29.578 4.18 1.696a.525.525 0 0 0 .654-.065zm-2.557 2.514a.483.483 0 0 0-.089-.745A4.47 4.47 0 0 0 8 10c-.83 0-1.605.247-2.188.4a.483.483 0 0 0-.089.745.525.525 0 0 0 .626.085A3.47 3.47 0 0 1 8 11c.488 0 .947.118 1.349.314a.525.525 0 0 0 .626-.085zM9.5 14.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"; |
87 | 87 |
|
| 88 | + // Bootstrap-icons clipboard glyph (two subpaths in one d string). |
| 89 | + const COPY_ICON = |
| 90 | + "M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"; |
| 91 | + // Bootstrap-icons check2 glyph, shown briefly after a successful copy. |
| 92 | + const COPY_DONE_ICON = |
| 93 | + "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0"; |
| 94 | + |
88 | 95 | // ===== Runtime state ===== |
89 | 96 |
|
90 | 97 | const app = document.getElementById("app"); |
|
369 | 376 | return svg; |
370 | 377 | } |
371 | 378 |
|
| 379 | + // copyTextToClipboard prefers the async Clipboard API but falls back to |
| 380 | + // an off-screen <textarea> + execCommand("copy"). The fallback is the |
| 381 | + // path that actually runs on a feeder: the webconfig is served over |
| 382 | + // plain HTTP on the LAN, which is not a secure context, so |
| 383 | + // navigator.clipboard is undefined. It is also used when the async API |
| 384 | + // is present but rejects (permission / user-activation quirks). |
| 385 | + // try/finally guarantees the textarea is removed even if select throws. |
| 386 | + function copyTextToClipboard(text) { |
| 387 | + const fallback = () => new Promise((resolve, reject) => { |
| 388 | + const ta = document.createElement("textarea"); |
| 389 | + ta.value = text; |
| 390 | + ta.setAttribute("readonly", ""); |
| 391 | + ta.style.position = "fixed"; |
| 392 | + ta.style.top = "-9999px"; |
| 393 | + ta.style.opacity = "0"; |
| 394 | + document.body.appendChild(ta); |
| 395 | + try { |
| 396 | + ta.focus({ preventScroll: true }); |
| 397 | + ta.select(); |
| 398 | + ta.setSelectionRange(0, ta.value.length); |
| 399 | + if (document.execCommand("copy")) resolve(); |
| 400 | + else reject(new Error("copy failed")); |
| 401 | + } catch (e) { |
| 402 | + reject(e); |
| 403 | + } finally { |
| 404 | + document.body.removeChild(ta); |
| 405 | + } |
| 406 | + }); |
| 407 | + if (navigator.clipboard && window.isSecureContext) { |
| 408 | + return navigator.clipboard.writeText(text).catch(fallback); |
| 409 | + } |
| 410 | + return fallback(); |
| 411 | + } |
| 412 | + |
| 413 | + // copyButton returns a small inline icon button that copies `value` and |
| 414 | + // briefly swaps to a check glyph (or an error state) for feedback. |
| 415 | + // `label` names the value for the aria-label / tooltip. The `gen` token |
| 416 | + // stops a slow or failed copy from overwriting the state of a newer |
| 417 | + // click, and stops a stale revert timer from firing. |
| 418 | + function copyButton(value, label) { |
| 419 | + const btn = el("button", { |
| 420 | + class: "wc-copy-btn", |
| 421 | + type: "button", |
| 422 | + "aria-label": "Copy " + label, |
| 423 | + title: "Copy " + label, |
| 424 | + }, svgIcon(COPY_ICON, 14)); |
| 425 | + let gen = 0; |
| 426 | + btn.addEventListener("click", async () => { |
| 427 | + const myGen = ++gen; |
| 428 | + let done = true; |
| 429 | + try { await copyTextToClipboard(value); } |
| 430 | + catch (_) { done = false; } |
| 431 | + if (myGen !== gen) return; |
| 432 | + btn.replaceChildren(svgIcon(done ? COPY_DONE_ICON : COPY_ICON, 14)); |
| 433 | + btn.classList.toggle("wc-copy-btn--done", done); |
| 434 | + btn.classList.toggle("wc-copy-btn--error", !done); |
| 435 | + btn.title = done ? "Copied" : "Copy failed — select manually"; |
| 436 | + btn.setAttribute("aria-label", |
| 437 | + done ? label + " copied" : "Copy failed — select manually"); |
| 438 | + setTimeout(() => { |
| 439 | + if (myGen !== gen) return; |
| 440 | + btn.replaceChildren(svgIcon(COPY_ICON, 14)); |
| 441 | + btn.classList.remove("wc-copy-btn--done", "wc-copy-btn--error"); |
| 442 | + btn.title = "Copy " + label; |
| 443 | + btn.setAttribute("aria-label", "Copy " + label); |
| 444 | + }, 1400); |
| 445 | + }); |
| 446 | + return btn; |
| 447 | + } |
| 448 | + |
372 | 449 | // ===== Safe claim URL ===== |
373 | 450 |
|
374 | 451 | function safeClaimHref(url) { |
|
1297 | 1374 | (r.payload && r.payload.error) || "reveal failed")); |
1298 | 1375 | return; |
1299 | 1376 | } |
| 1377 | + if (!r.payload.feeder_id || !r.payload.claim_secret) { |
| 1378 | + parent.replaceChildren(el("p", { class: "error", role: "alert" }, |
| 1379 | + "reveal returned incomplete data")); |
| 1380 | + return; |
| 1381 | + } |
1300 | 1382 | const safe = safeClaimHref(r.payload.claim_page); |
1301 | 1383 | const linkOrText = safe |
1302 | 1384 | ? el("a", { href: safe, target: "_blank", rel: "noopener noreferrer" }, "Claim this feeder") |
1303 | 1385 | : el("span", { class: "muted" }, r.payload.claim_page || ""); |
1304 | 1386 | parent.replaceChildren( |
1305 | | - el("p", {}, el("strong", {}, "Feeder ID: "), r.payload.feeder_id), |
1306 | | - el("p", {}, el("strong", {}, "Claim secret: "), el("code", {}, r.payload.claim_secret)), |
| 1387 | + el("p", { class: "wc-copy-row" }, |
| 1388 | + el("strong", {}, "Feeder ID: "), |
| 1389 | + el("span", { class: "wc-copy-val" }, r.payload.feeder_id), |
| 1390 | + copyButton(r.payload.feeder_id, "feeder ID")), |
| 1391 | + el("p", { class: "wc-copy-row" }, |
| 1392 | + el("strong", {}, "Claim secret: "), |
| 1393 | + el("code", { class: "wc-copy-val" }, r.payload.claim_secret), |
| 1394 | + copyButton(r.payload.claim_secret, "claim secret")), |
1307 | 1395 | el("p", {}, linkOrText), |
1308 | 1396 | el("div", { class: "wc-action-grid" }, claimLog), |
1309 | 1397 | ); |
|
0 commit comments