From 175c57e96e0e32713fe4497082fc3ce54c0b6dd9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 4 Jun 2026 20:29:57 -0400 Subject: [PATCH] Add copyable shareable links for individual stories The story sharing surface exposed only social share icons and a count, with no way to grab the direct URL for a story to paste into email or posts. Stakeholders need a copy-paste link per story (issue #1523). Adds a reusable share section (direct URL + copy button, plus email and social targets) to the public story share page and the story show page, backed by a small copy-link Stimulus controller. The duplicated inline social markup on the story show page is replaced by the shared partial. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- .../controllers/copy_link_controller.js | 38 ++++++++++++++ app/frontend/javascript/controllers/index.js | 3 ++ app/views/stories/_share_links.html.erb | 50 +++++++++++++++++++ app/views/stories/show.html.erb | 37 +------------- app/views/story_shares/show.html.erb | 3 ++ spec/requests/story_share_spec.rb | 6 +++ 7 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 app/frontend/javascript/controllers/copy_link_controller.js create mode 100644 app/views/stories/_share_links.html.erb 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

+ +
+ + +
+ +
+ <%= 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)