diff --git a/static/app.css b/static/app.css index 527e8fd..fce93c4 100644 --- a/static/app.css +++ b/static/app.css @@ -451,6 +451,9 @@ body { .cli-install-row { align-items: stretch; } .cli-install-cmd { display: inline-block; padding: 8px 12px; border-radius: 6px; background: var(--bg); border: 1px solid var(--border); font-size: 14px; user-select: all; overflow-x: auto; max-width: 100%; } +.icon-btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 10px; } +.icon-btn svg { width: 16px; height: 16px; } +.icon-btn.ok { color: var(--primary); border-color: var(--primary); } .tools-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .tools-head h1 { margin: 0; } diff --git a/static/app.js b/static/app.js index 531c980..b0da4a9 100644 --- a/static/app.js +++ b/static/app.js @@ -155,6 +155,25 @@ function escapeHtml(s) { return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } +// navigator.clipboard only exists in secure contexts (HTTPS/localhost); +// fall back to a hidden textarea + execCommand for plain-HTTP LAN use. +async function copyText(text) { + if (window.isSecureContext && navigator.clipboard) { + try { await navigator.clipboard.writeText(text); return true; } catch (e) { /* fall through */ } + } + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand("copy"); } catch (e) {} + ta.remove(); + return ok; +} + async function boot() { try { const me = await api("GET", "/api/me"); @@ -261,9 +280,22 @@ function showCli() { const back = backButton(showHome); const installCmd = "curl -fsSL " + window.location.origin + "/" + state.user + "/cli | bash"; const cmdEl = el("code", { class: "cli-install-cmd", text: installCmd }); - const copyBtn = el("button", { - class: "btn", text: "Copy", - onclick: () => { navigator.clipboard?.writeText(installCmd); copyBtn.textContent = "Copied!"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); }, + const CLIP_ICON = ''; + const CHECK_ICON = ''; + const copyBtn = el("button", { class: "btn icon-btn", title: "Copy install command", "aria-label": "Copy install command", html: CLIP_ICON }); + copyBtn.addEventListener("click", async () => { + const ok = await copyText(installCmd); + if (!ok) { // last resort: select the text so the user can ⌘C + const sel = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(cmdEl); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + copyBtn.innerHTML = CHECK_ICON; + copyBtn.classList.add("ok"); + setTimeout(() => { copyBtn.innerHTML = CLIP_ICON; copyBtn.classList.remove("ok"); }, 2000); }); const card = el("div", { class: "help-card" }, [