Skip to content
Open
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 @@ -267,6 +267,7 @@ end
- `dropdown` — Dropdown menus with keyboard/click-outside handling
- `file_preview` — File upload preview
- `inactive_toggle` — Gray out expired affiliations
- `lightbox` — Inline image gallery modal with next/previous navigation
- `optimistic_bookmark` — Instant bookmark UI feedback
- `org_toggle` — Organization toggle UI
- `paginated_fields` — Client-side pagination of nested fields
Expand Down
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@ application.register("remote-select", RemoteSelectController)
import MixedChartController from "./mixed_chart_controller"
application.register("mixed-chart", MixedChartController)

import LightboxController from "./lightbox_controller"
application.register("lightbox", LightboxController)

75 changes: 75 additions & 0 deletions app/frontend/javascript/controllers/lightbox_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="lightbox"
//
// Opens gallery images in an inline modal instead of navigating away, and lets
// the viewer scroll through all of the gallery's images with next/previous
// controls, the arrow keys, or by closing with Escape / a backdrop click.
//
// Each clickable image is an "item" target whose href points at the full-size
// image. The modal, image, counter, and nav buttons live in the same scope.
export default class extends Controller {
static targets = ["item", "modal", "image", "counter", "prev", "next"]
static values = { index: Number }

open(event) {
const index = this.itemTargets.indexOf(event.currentTarget)
if (index === -1) return

this.indexValue = index
this.modalTarget.classList.remove("hidden")
this.modalTarget.classList.add("flex")
document.body.classList.add("overflow-hidden")
this.modalTarget.focus()
}

close() {
this.modalTarget.classList.add("hidden")
this.modalTarget.classList.remove("flex")
document.body.classList.remove("overflow-hidden")
}

next() {
if (this.isClosed) return
this.indexValue = (this.indexValue + 1) % this.itemTargets.length
}

prev() {
if (this.isClosed) return
const count = this.itemTargets.length
this.indexValue = (this.indexValue - 1 + count) % count
}

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.

Prev/next wrap around the set (modulo length) so the viewer can keep scrolling in either direction without hitting a dead end.

closeOnEscape() {
if (this.isClosed) return
this.close()
}

backdropClose(event) {
if (event.target === this.modalTarget) this.close()
}

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.

The full-size image URL is read from each item's href rather than a separate data attribute. That keeps a single source of truth and means the anchors double as a no-JS fallback (they still link to the file when Stimulus isn't loaded).

indexValueChanged() {
const item = this.itemTargets[this.indexValue]
if (!item) return

this.imageTarget.src = item.getAttribute("href")
this.imageTarget.alt = item.dataset.caption || ""

if (this.hasCounterTarget) {
this.counterTarget.textContent = `${this.indexValue + 1} of ${this.itemTargets.length}`
}

const single = this.itemTargets.length <= 1
if (this.hasPrevTarget) this.prevTarget.classList.toggle("hidden", single)
if (this.hasNextTarget) this.nextTarget.classList.toggle("hidden", single)
}

disconnect() {
document.body.classList.remove("overflow-hidden")
}

get isClosed() {
return this.modalTarget.classList.contains("hidden")
}
}
22 changes: 19 additions & 3 deletions app/views/assets/_display_gallery_media.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
<!-- Gallery Media -->
<% gallery_assets = resource.gallery_assets.select { |img| img.file.attached? } %>
<% align = (defined?(align) && align.present?) ? align.to_s : "center" %>
<% link = (defined?(link) ? link : nil) %>
<% if gallery_assets.any? %>
<div class="<%= resource.class.table_name %>-gallery text-<%= align %> mb-4">
<div class="<%= resource.class.table_name %>-gallery text-<%= align %> mb-4"
data-controller="lightbox"
data-action="keydown.esc@window->lightbox#closeOnEscape keydown.left@window->lightbox#prev keydown.right@window->lightbox#next">
Comment on lines +6 to +8
<div class="flex flex-wrap justify-<%= align %> gap-4 <%= 'mx-auto' if align == 'center' %> max-w-4xl">
<% gallery_assets.each_with_index do |gallery_assets, idx| %>
<%= render "assets/display_image", item: gallery_assets, idx: idx, variant: :gallery, link: (defined?(link) ? link : nil) %>
<% gallery_assets.each_with_index do |gallery_asset, idx| %>
<% if link && gallery_asset.file.content_type.to_s.start_with?("image/") %>

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.

Lightbox is gated on link (the existing clickable-gallery flag) AND the asset being an image. Non-image gallery assets (e.g. PDFs) fall through to the original display_image rendering, and galleries rendered without link (e.g. reports) keep their previous non-clickable behavior.

<a href="<%= url_for(gallery_asset.file) %>"
class="flex grow"
data-lightbox-target="item"

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.

The anchor keeps href pointing at the full-size file (no-JS fallback) and data-action="lightbox#open:prevent" intercepts the click when JS is active. Inner image is rendered with link: false so we don't nest anchors.

data-caption="<%= gallery_asset.file.filename %>"
data-action="lightbox#open:prevent">
<%= render "assets/display_image", item: gallery_asset, idx: idx, variant: :gallery, link: false %>
</a>
<% else %>
<%= render "assets/display_image", item: gallery_asset, idx: idx, variant: :gallery, link: link %>
<% end %>
<% end %>
</div>
<% if link %>
<%= render "assets/lightbox" %>
<% end %>
</div>
<% end %>
35 changes: 35 additions & 0 deletions app/views/assets/_lightbox.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<div class="hidden fixed inset-0 z-50 items-center justify-center bg-black/80 p-4"
tabindex="-1"
role="dialog"
aria-modal="true"
aria-label="Image gallery"
data-lightbox-target="modal"
data-action="click->lightbox#backdropClose">
<button type="button"
class="absolute top-4 right-4 text-white/80 hover:text-white text-3xl leading-none px-2"
aria-label="Close"
data-action="lightbox#close">
<i class="fa-solid fa-xmark"></i>
</button>
<button type="button"
class="absolute left-2 sm:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white text-4xl leading-none px-2"
aria-label="Previous image"
data-lightbox-target="prev"
data-action="lightbox#prev">
<i class="fa-solid fa-chevron-left"></i>
</button>
<figure class="flex max-h-full max-w-4xl flex-col items-center">
<img class="max-h-[80vh] max-w-full rounded object-contain shadow-lg"
alt=""
data-lightbox-target="image">
<figcaption class="mt-3 text-center text-sm text-white/80"
data-lightbox-target="counter"></figcaption>
</figure>
<button type="button"
class="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white text-4xl leading-none px-2"
aria-label="Next image"
data-lightbox-target="next"
data-action="lightbox#next">
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
40 changes: 40 additions & 0 deletions spec/system/story_gallery_lightbox_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
require "rails_helper"

RSpec.describe "Story gallery lightbox", type: :system do
before { driven_by(:selenium_chrome_headless) }

it "opens additional images inline and scrolls through them" do
sign_in create(:user)

story = create(:story, :published, title: "A story with extra images")
create(:gallery_asset, :with_file, owner: story)
create(:gallery_asset, :with_file, owner: story)

visit story_path(story)

expect(page).to have_css("[data-controller='lightbox']")
expect(page).to have_css("[data-lightbox-target='item']", count: 2)

# Modal starts hidden
modal = find("[data-lightbox-target='modal']", visible: :all)
expect(modal[:class]).to include("hidden")

# Clicking an additional image opens it inline rather than navigating away
all("[data-lightbox-target='item']").first.click

expect(page).to have_current_path(story_path(story))
expect(modal[:class]).not_to include("hidden")
expect(page).to have_css("[data-lightbox-target='counter']", text: "1 of 2")

# Next/previous controls scroll through the set
find("[data-lightbox-target='next']").click
expect(page).to have_css("[data-lightbox-target='counter']", text: "2 of 2")

find("[data-lightbox-target='prev']").click
expect(page).to have_css("[data-lightbox-target='counter']", text: "1 of 2")

# Closing dismisses the modal
find("[data-action='lightbox#close']").click
expect(modal[:class]).to include("hidden")
end
end