diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js index 7e0b1a493..d895ac7b8 100644 --- a/app/javascript/controllers/clipboard_controller.js +++ b/app/javascript/controllers/clipboard_controller.js @@ -1,28 +1,69 @@ import { Controller } from "@hotwired/stimulus"; +// Single source of truth so the icon-swap and label-flash feedback last the +// same time when both copy buttons appear on one page. +const RESET_DELAY_MS = 2000; + export default class extends Controller { static targets = ["source", "iconDefault", "iconSuccess"]; + static values = { copiedText: String }; copy(event) { event.preventDefault(); - if (this.sourceTarget?.textContent) { - navigator.clipboard - .writeText(this.sourceTarget.textContent) - .then(() => { - this.showSuccess(); - }) - .catch((error) => { - console.error("Failed to copy text: ", error); - }); - } + // Capture the button now: `event.currentTarget` is reset to null once the + // event finishes dispatching, so it can't be read inside the async `.then`. + const button = event.currentTarget; + const text = this.sourceTarget?.textContent; + if (!text) return; + + navigator.clipboard + .writeText(text) + .then(() => { + this.showSuccess(button); + }) + .catch((error) => { + console.error("Failed to copy text: ", error); + }); } - showSuccess() { - this.iconDefaultTarget.classList.add("hidden"); - this.iconSuccessTarget.classList.remove("hidden"); - setTimeout(() => { - this.iconDefaultTarget.classList.remove("hidden"); - this.iconSuccessTarget.classList.add("hidden"); - }, 3000); + showSuccess(button) { + // Markup that ships explicit default/success icons (invite codes, MFA, + // profiles) toggles between them. + if (this.hasIconDefaultTarget && this.hasIconSuccessTarget) { + this.iconDefaultTarget.classList.add("hidden"); + this.iconSuccessTarget.classList.remove("hidden"); + setTimeout(() => { + this.iconDefaultTarget.classList.remove("hidden"); + this.iconSuccessTarget.classList.add("hidden"); + }, RESET_DELAY_MS); + return; + } + + // A single-icon button (e.g. DS::Button) has no icons to swap, so confirm + // the copy by briefly flipping the button's own label. + this.flashLabel(button); + } + + flashLabel(button) { + // DS::Button renders its icon as an and its text in `span.truncate`, + // so scope to that class rather than the first in case an icon ever + // ships wrapped in a span. + const label = button?.querySelector("span.truncate") ?? button?.querySelector("span"); + if (!label || !this.hasCopiedTextValue) return; + + clearTimeout(this.labelResetTimer); + if (this.originalLabel == null) { + this.originalLabel = label.textContent; + } + + label.textContent = this.copiedTextValue; + this.labelResetTimer = setTimeout(() => { + label.textContent = this.originalLabel; + this.originalLabel = null; + }, RESET_DELAY_MS); + } + + disconnect() { + clearTimeout(this.labelResetTimer); } } diff --git a/app/views/settings/mcp/show.html.erb b/app/views/settings/mcp/show.html.erb index 7e4160d21..b39e3c052 100644 --- a/app/views/settings/mcp/show.html.erb +++ b/app/views/settings/mcp/show.html.erb @@ -8,7 +8,7 @@

<%= t(".connect_subtitle") %>

-
+
">
<%= @mcp_url %> <%= render DS::Button.new( diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 9a5802d7c..2d9a98651 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -236,6 +236,7 @@ en: connect_title: Connect an AI assistant connect_subtitle: Paste this URL into Claude.ai (or any MCP-compatible client) to connect it to your Sure account. copy_url: Copy + copied: Copied! how_to_connect_title: How to connect Claude step_1: "Open Claude.ai and go to Settings → Integrations." step_2: Click "Add integration" and paste the MCP server URL above.