Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions app/frontend/javascript/controllers/copy_link_controller.js
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback path for browsers/contexts where the async Clipboard API is unavailable (e.g. non-HTTPS or older Safari). execCommand("copy") is deprecated but still the most reliable cross-browser fallback.

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)
}
}
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
50 changes: 50 additions & 0 deletions app/views/stories/_share_links.html.erb
Original file line number Diff line number Diff line change
@@ -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). %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial takes url as a local rather than deriving it, so each caller passes the appropriate canonical URL for its context while keeping the markup shared.

<% encoded_url = CGI.escape(url) %>
<% encoded_title = CGI.escape(story.title) %>
Comment on lines +3 to +4
<div class="border-t border-gray-300 pt-6 mt-6" data-controller="copy-link">
<h3 class="font-semibold text-lg mb-3">Share this story</h3>

<div class="flex flex-col sm:flex-row gap-2 sm:items-center mb-4">
<input
type="text"
data-copy-link-target="input"
value="<%= url %>"
readonly
aria-label="Direct link to this story"
class="w-full sm:max-w-md px-3 py-2 bg-gray-100 border border-gray-300 rounded font-mono text-sm overflow-x-auto whitespace-nowrap cursor-text"
>
Comment on lines +15 to +16
<button type="button" data-action="copy-link#copy" class="btn btn-secondary-outline whitespace-nowrap">
<i class="fa-solid fa-link mr-1"></i>
<span data-copy-link-target="label">Copy link</span>
</button>
</div>

<div class="flex space-x-4">
<%= link_to "mailto:?subject=#{encoded_title}&body=#{encoded_url}",
class: "text-gray-600 hover:text-gray-800",
title: "Share by email" do %>
Comment on lines +24 to +26
<i class="fa-solid fa-envelope fa-lg"></i>
<% 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 %>
Comment on lines +29 to +31
<i class="fab fa-facebook-square fa-lg"></i>
<% 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 %>
Comment on lines +34 to +36
<i class="fab fa-x-twitter fa-lg"></i>
<% 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 %>
Comment on lines +39 to +41
<i class="fab fa-linkedin fa-lg"></i>
<% 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 %>
Comment on lines +44 to +46
<i class="fab fa-pinterest-square fa-lg"></i>
<% end %>
</div>
</div>
37 changes: 2 additions & 35 deletions app/views/stories/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<% story_url = story_url(@story) %>
<% story_title = ERB::Util.url_encode(@story.title) %>
<% share_url = story_share_url(@story) %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched the copyable link from story_url (admin /stories/:id, auth-gated) to the canonical public story_share_url, so a copied link is actually shareable with non-logged-in recipients.


<% content_for(:page_bg_class, "admin-or-public-or-authpublished") %>
<div class="<%= DomainTheme.bg_class_for(:stories) %> border border-gray-200 rounded-xl shadow p-6">
Expand Down Expand Up @@ -69,39 +68,7 @@
</div>

<!-- Share Section -->
<h3 class="font-semibold text-lg mb-3">Share this Story:</h3>

<div class="flex space-x-4 mb-6">

<!-- Facebook -->
<%= 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 %>
<i class="fab fa-facebook-square fa-lg"></i>
<% end %>

<!-- X / Twitter -->
<%= 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 %>
<i class="fab fa-x-twitter fa-lg"></i>
<% end %>

<!-- LinkedIn -->
<%= 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 %>
<i class="fab fa-linkedin fa-lg"></i>
<% end %>

<!-- Pinterest -->
<%= 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 %>
<i class="fab fa-pinterest-square fa-lg"></i>
<% end %>

</div>
<%= render "stories/share_links", story: @story, url: share_url %>

<div class="text-sm text-gray-500 leading-relaxed border-t border-gray-300 pt-6">
<%= render "shared/organization_footer" %>
Expand Down
3 changes: 3 additions & 0 deletions app/views/story_shares/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
<%= render "assets/display_assets", resource: @story, link: true %>
</div>
<% end %>

<!-- Share Section -->
<%= render "stories/share_links", story: @story, url: story_share_url(@story) %>
</div>
</section>

Expand Down
6 changes: 6 additions & 0 deletions spec/requests/story_share_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading