diff --git a/AGENTS.md b/AGENTS.md
index 6f769f00e..7158ed8bd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,7 +71,7 @@ This codebase (Rails 8.1)
| Directory | Purpose |
|---|---|
| `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) |
-| `app/frontend/javascript/controllers/` | Stimulus controllers (34) |
+| `app/frontend/javascript/controllers/` | Stimulus controllers (35) |
| `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) |
| `app/frontend/stylesheets/` | Tailwind CSS and component styles |
@@ -262,6 +262,7 @@ end
- `column_toggle` — Toggle table column visibility
- `comment_edit_toggle` — Inline comment editing mode
- `confirm_email` — Email confirmation UI
+- `copy_link` — Copy a direct shareable URL to the clipboard
- `dirty_form` — Unsaved changes detection
- `dismiss` — Dismissable elements
- `dropdown` — Dropdown menus with keyboard/click-outside handling
diff --git a/app/frontend/javascript/controllers/copy_link_controller.js b/app/frontend/javascript/controllers/copy_link_controller.js
new file mode 100644
index 000000000..a74e5d656
--- /dev/null
+++ b/app/frontend/javascript/controllers/copy_link_controller.js
@@ -0,0 +1,38 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="copy-link"
+// Copies a direct shareable URL to the clipboard and gives the user feedback.
+export default class extends Controller {
+ static targets = ["input", "label"]
+ static values = { confirmedLabel: { type: String, default: "Copied!" } }
+
+ async copy() {
+ const url = this.inputTarget.value
+
+ try {
+ await navigator.clipboard.writeText(url)
+ } catch {
+ // Fallback for browsers without the async clipboard API
+ this.inputTarget.select()
+ document.execCommand("copy")
+ }
+
+ this.confirm()
+ }
+
+ confirm() {
+ if (!this.hasLabelTarget) return
+
+ const original = this.labelTarget.textContent
+ this.labelTarget.textContent = this.confirmedLabelValue
+
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
+ this.resetTimeout = setTimeout(() => {
+ this.labelTarget.textContent = original
+ }, 2000)
+ }
+
+ disconnect() {
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
+ }
+}
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index 2fdbf1a09..6cc6900f2 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -30,6 +30,9 @@ application.register("column-toggle", ColumnToggleController)
import CommentEditToggleController from "./comment_edit_toggle_controller"
application.register("comment-edit-toggle", CommentEditToggleController)
+import CopyLinkController from "./copy_link_controller"
+application.register("copy-link", CopyLinkController)
+
import DirtyFormController from "./dirty_form_controller"
application.register("dirty-form", DirtyFormController)
diff --git a/app/views/stories/_share_links.html.erb b/app/views/stories/_share_links.html.erb
new file mode 100644
index 000000000..800432b74
--- /dev/null
+++ b/app/views/stories/_share_links.html.erb
@@ -0,0 +1,50 @@
+<%# Direct shareable link + social/email share targets for a single story.
+ Locals: story (decorated or plain), url (canonical public share URL string). %>
+<% encoded_url = CGI.escape(url) %>
+<% encoded_title = CGI.escape(story.title) %>
+
+
Share this story
+
+
+
+
+
+ Copy link
+
+
+
+
+ <%= link_to "mailto:?subject=#{encoded_title}&body=#{encoded_url}",
+ class: "text-gray-600 hover:text-gray-800",
+ title: "Share by email" do %>
+
+ <% end %>
+ <%= link_to "https://www.facebook.com/sharer/sharer.php?u=#{encoded_url}",
+ target: "_blank", rel: "noopener noreferrer",
+ class: "text-blue-600 hover:text-blue-800", title: "Share on Facebook" do %>
+
+ <% end %>
+ <%= link_to "https://twitter.com/intent/tweet?url=#{encoded_url}&text=#{encoded_title}",
+ target: "_blank", rel: "noopener noreferrer",
+ class: "text-sky-500 hover:text-sky-700", title: "Share on X" do %>
+
+ <% end %>
+ <%= link_to "https://www.linkedin.com/sharing/share-offsite/?url=#{encoded_url}",
+ target: "_blank", rel: "noopener noreferrer",
+ class: "text-blue-700 hover:text-blue-900", title: "Share on LinkedIn" do %>
+
+ <% end %>
+ <%= link_to "https://pinterest.com/pin/create/button/?url=#{encoded_url}&description=#{encoded_title}",
+ target: "_blank", rel: "noopener noreferrer",
+ class: "text-red-600 hover:text-red-800", title: "Share on Pinterest" do %>
+
+ <% end %>
+
+
diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb
index c2f3f28ee..c580bbde1 100644
--- a/app/views/stories/show.html.erb
+++ b/app/views/stories/show.html.erb
@@ -1,5 +1,4 @@
-<% story_url = story_url(@story) %>
-<% story_title = ERB::Util.url_encode(@story.title) %>
+<% share_url = story_share_url(@story) %>
<% content_for(:page_bg_class, "admin-or-public-or-authpublished") %>
@@ -69,39 +68,7 @@
- Share this Story:
-
-
-
-
- <%= link_to "https://www.facebook.com/sharer/sharer.php?u=#{story_url}",
- target: "_blank", rel: "noopener noreferrer",
- class: "text-blue-600 hover:text-blue-800" do %>
-
- <% end %>
-
-
- <%= link_to "https://twitter.com/intent/tweet?url=#{story_url}&text=#{story_title}",
- target: "_blank", rel: "noopener noreferrer",
- class: "text-sky-500 hover:text-sky-700" do %>
-
- <% end %>
-
-
- <%= link_to "https://www.linkedin.com/sharing/share-offsite/?url=#{story_url}",
- target: "_blank", rel: "noopener noreferrer",
- class: "text-blue-700 hover:text-blue-900" do %>
-
- <% end %>
-
-
- <%= link_to "https://pinterest.com/pin/create/button/?url=#{story_url}&description=#{story_title}",
- target: "_blank", rel: "noopener noreferrer",
- class: "text-red-600 hover:text-red-800" do %>
-
- <% end %>
-
-
+ <%= render "stories/share_links", story: @story, url: share_url %>
<%= render "shared/organization_footer" %>
diff --git a/app/views/story_shares/show.html.erb b/app/views/story_shares/show.html.erb
index 0716568c2..b0b91d0b8 100644
--- a/app/views/story_shares/show.html.erb
+++ b/app/views/story_shares/show.html.erb
@@ -84,6 +84,9 @@
<%= render "assets/display_assets", resource: @story, link: true %>
<% end %>
+
+
+ <%= render "stories/share_links", story: @story, url: story_share_url(@story) %>
diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb
index 756681783..526d986fa 100644
--- a/spec/requests/story_share_spec.rb
+++ b/spec/requests/story_share_spec.rb
@@ -103,6 +103,12 @@
expect(response).to have_http_status(:ok)
end
+ it "renders a copyable direct link to the story" do
+ get story_share_path(public_story)
+ expect(response.body).to include(story_share_url(public_story))
+ expect(response.body).to include("Copy link")
+ end
+
it "cannot view published-only story" do
get story_share_path(published_story)
expect(response).to redirect_to(root_path)