From 2753e588636f839a5807c84e44fb534fb4f682a0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 4 Jun 2026 20:42:36 -0400 Subject: [PATCH] Open additional story images in an inline lightbox Published stories link each additional (gallery) image to its full-size file, so clicking one navigated the viewer away from the story and gave no way to browse the images as a set. Wrap gallery images in a lightbox that opens the image in place and lets the viewer scroll through the whole set with next/previous controls, arrow keys, Escape, and a backdrop click. The underlying links are preserved as a no-JS fallback. Closes #1520 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- app/frontend/javascript/controllers/index.js | 3 + .../controllers/lightbox_controller.js | 75 +++++++++++++++++++ .../assets/_display_gallery_media.html.erb | 22 +++++- app/views/assets/_lightbox.html.erb | 35 +++++++++ spec/system/story_gallery_lightbox_spec.rb | 40 ++++++++++ 6 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 app/frontend/javascript/controllers/lightbox_controller.js create mode 100644 app/views/assets/_lightbox.html.erb create mode 100644 spec/system/story_gallery_lightbox_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 6f769f00e..5c50623d8 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 | @@ -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 diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 2fdbf1a09..b0d78518c 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -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) + diff --git a/app/frontend/javascript/controllers/lightbox_controller.js b/app/frontend/javascript/controllers/lightbox_controller.js new file mode 100644 index 000000000..9d9b251c3 --- /dev/null +++ b/app/frontend/javascript/controllers/lightbox_controller.js @@ -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 + } + + closeOnEscape() { + if (this.isClosed) return + this.close() + } + + backdropClose(event) { + if (event.target === this.modalTarget) this.close() + } + + 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") + } +} diff --git a/app/views/assets/_display_gallery_media.html.erb b/app/views/assets/_display_gallery_media.html.erb index 57c24d69e..922e91e65 100644 --- a/app/views/assets/_display_gallery_media.html.erb +++ b/app/views/assets/_display_gallery_media.html.erb @@ -1,12 +1,28 @@ <% 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? %> -