Skip to content

Commit fb364ce

Browse files
committed
feat(ui): add copy buttons to feeder ID and claim secret
The claim view previously required manually selecting the feeder ID and claim secret to copy them. Add a small inline copy icon after each value that copies it in one tap, with brief visual feedback. Falls back to execCommand where the async clipboard API is unavailable (the webconfig is served over plain HTTP, not a secure context).
1 parent 06acb01 commit fb364ce

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

web/assets/app.js

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@
8585
const WIFI_ICON =
8686
"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";
8787

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+
8895
// ===== Runtime state =====
8996

9097
const app = document.getElementById("app");
@@ -369,6 +376,76 @@
369376
return svg;
370377
}
371378

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+
372449
// ===== Safe claim URL =====
373450

374451
function safeClaimHref(url) {
@@ -1297,13 +1374,24 @@
12971374
(r.payload && r.payload.error) || "reveal failed"));
12981375
return;
12991376
}
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+
}
13001382
const safe = safeClaimHref(r.payload.claim_page);
13011383
const linkOrText = safe
13021384
? el("a", { href: safe, target: "_blank", rel: "noopener noreferrer" }, "Claim this feeder")
13031385
: el("span", { class: "muted" }, r.payload.claim_page || "");
13041386
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")),
13071395
el("p", {}, linkOrText),
13081396
el("div", { class: "wc-action-grid" }, claimLog),
13091397
);

web/assets/style.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,43 @@ button.secondary:hover,
674674
.wc-btn-icon--spinning svg { animation: none; }
675675
}
676676

677+
/* Copyable value row in the claim box: label + value + a small inline copy
678+
button that sits right after the value and is sized to the text, not the
679+
2.75rem header icon buttons. The value may wrap (overflow-wrap) so a long
680+
feeder UUID can't push the button off a narrow screen. */
681+
.wc-copy-row {
682+
display: flex;
683+
align-items: center;
684+
flex-wrap: wrap;
685+
gap: 0.3rem;
686+
}
687+
.wc-copy-val {
688+
min-width: 0;
689+
overflow-wrap: anywhere;
690+
}
691+
.wc-copy-btn {
692+
display: inline-flex;
693+
align-items: center;
694+
justify-content: center;
695+
width: auto;
696+
min-width: 0;
697+
min-height: 0;
698+
/* Transparent padding: the glyph stays text-sized at rest, but the tap
699+
target grows to ~24px for touch use. */
700+
padding: 0.3rem;
701+
line-height: 0;
702+
background: transparent;
703+
border: none;
704+
border-radius: var(--wc-radius-sm);
705+
color: var(--wc-ink-3);
706+
flex: 0 0 auto;
707+
}
708+
.wc-copy-btn:hover { color: var(--wc-ink-1); background: var(--wc-surface-3); }
709+
.wc-copy-btn--done,
710+
.wc-copy-btn--done:hover { color: var(--wc-success); background: transparent; }
711+
.wc-copy-btn--error,
712+
.wc-copy-btn--error:hover { color: var(--wc-danger); background: transparent; }
713+
677714
/* The header back button visually matches an icon button when collapsed
678715
on mobile but stretches with text on desktop — keep it as a normal
679716
ghost button. */

0 commit comments

Comments
 (0)