From c1d3f25d50b9a2adf4f05bdf462ffcd43a92e383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 16:45:49 +0100 Subject: [PATCH 1/3] WIP Add basic photo gallery --- app/components/place-photos-carousel.gjs | 28 ++++++++- app/styles/app.css | 78 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/app/components/place-photos-carousel.gjs b/app/components/place-photos-carousel.gjs index 4c115bc..3a31ae7 100644 --- a/app/components/place-photos-carousel.gjs +++ b/app/components/place-photos-carousel.gjs @@ -1,8 +1,10 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { fn } from '@ember/helper'; import Blurhash from './blurhash'; import Icon from './icon'; +import PhotoGallery from './photo-gallery'; import fadeInImage from '../modifiers/fade-in-image'; import { on } from '@ember/modifier'; import { modifier } from 'ember-modifier'; @@ -10,6 +12,8 @@ import { modifier } from 'ember-modifier'; export default class PlacePhotosCarousel extends Component { @tracked canScrollLeft = false; @tracked canScrollRight = false; + @tracked isGalleryOpen = false; + @tracked selectedPhoto = null; carouselElement = null; @@ -103,6 +107,18 @@ export default class PlacePhotosCarousel extends Component { }); } + @action + openGallery(photo) { + this.selectedPhoto = photo; + this.isGalleryOpen = true; + } + + @action + closeGallery() { + this.isGalleryOpen = false; + this.selectedPhoto = null; + } + } diff --git a/app/styles/app.css b/app/styles/app.css index 0be48a5..1df9504 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -909,6 +909,7 @@ abbr[title] { } .carousel-slide { + cursor: pointer; position: relative; flex: 0 0 100%; scroll-snap-align: start; @@ -1866,3 +1867,80 @@ button.create-place { .btn-link:hover { text-decoration: underline; } + +/* Photo Gallery */ +.photo-gallery-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 80%); + z-index: 9999; + display: flex; + flex-direction: column; +} + +.photo-gallery-overlay .photo-gallery-content { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +.photo-gallery-overlay .close-btn { + position: absolute; + top: 0.5rem; + right: 1rem; + width: 48px; + height: 48px; + z-index: 10; + color: white; + background: transparent; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +@media (width <= 768px) { + .photo-gallery-overlay .close-btn { + right: 0.5rem; + } +} + +.photo-gallery-overlay .main-photo-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + overflow: hidden; +} + +.photo-gallery-overlay .main-photo-container img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.photo-gallery-overlay .thumbnail-strip { + height: 100px; + display: flex; + gap: 0.5rem; + padding: 1rem; + overflow-x: auto; + background: rgb(0 0 0 / 50%); +} + +.photo-gallery-overlay .thumbnail-strip .thumbnail { + height: 100%; + flex-shrink: 0; + cursor: pointer; +} + +.photo-gallery-overlay .thumbnail-strip img { + height: 100%; + width: auto; + object-fit: cover; + border-radius: 4px; +} From 4f4ca827b1d09b79108b53fb9d669253dfd37d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 18:30:51 +0100 Subject: [PATCH 2/3] Refactor carousel and gallery to share the carousel component And make the gallery awesome --- ...photos-carousel.gjs => photo-carousel.gjs} | 121 ++++++++++++----- app/components/photo-gallery.gjs | 85 ++++++++++++ app/components/place-details.gjs | 39 ++++-- app/modifiers/fade-in-image.js | 18 +++ app/styles/app.css | 128 ++++++++++++++---- 5 files changed, 321 insertions(+), 70 deletions(-) rename app/components/{place-photos-carousel.gjs => photo-carousel.gjs} (62%) create mode 100644 app/components/photo-gallery.gjs diff --git a/app/components/place-photos-carousel.gjs b/app/components/photo-carousel.gjs similarity index 62% rename from app/components/place-photos-carousel.gjs rename to app/components/photo-carousel.gjs index 3a31ae7..c4ffa07 100644 --- a/app/components/place-photos-carousel.gjs +++ b/app/components/photo-carousel.gjs @@ -2,19 +2,18 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { fn } from '@ember/helper'; +import { and, eq } from 'ember-truth-helpers'; import Blurhash from './blurhash'; import Icon from './icon'; -import PhotoGallery from './photo-gallery'; import fadeInImage from '../modifiers/fade-in-image'; import { on } from '@ember/modifier'; import { modifier } from 'ember-modifier'; -export default class PlacePhotosCarousel extends Component { +export default class PhotoCarousel extends Component { @tracked canScrollLeft = false; @tracked canScrollRight = false; - @tracked isGalleryOpen = false; - @tracked selectedPhoto = null; + internalEventId = null; carouselElement = null; get photos() { @@ -22,6 +21,7 @@ export default class PlacePhotosCarousel extends Component { } get showChevrons() { + // Only show chevrons if there's more than one photo return this.photos.length > 1; } @@ -33,6 +33,10 @@ export default class PlacePhotosCarousel extends Component { return !this.canScrollRight; } + get variantClass() { + return this.args.variant || 'inline'; + } + lastResetKey = null; resetScrollPosition = modifier((element, [resetKey]) => { @@ -45,16 +49,42 @@ export default class PlacePhotosCarousel extends Component { scrollToNewPhoto = modifier((element, [eventId]) => { if (eventId && eventId !== this.lastEventId) { + const isInitial = !this.lastEventId; this.lastEventId = eventId; - // Allow DOM to update first since the photo was *just* added to the store - setTimeout(() => { + + // Prevent feedback loop if this carousel initiated the change + if (this.internalEventId === eventId) { + return; + } + + const scrollAction = () => { const targetSlide = element.querySelector( `[data-event-id="${eventId}"]` ); if (targetSlide) { - element.scrollLeft = targetSlide.offsetLeft; + if (isInitial) { + const originalScrollBehavior = element.style.scrollBehavior; + element.style.scrollBehavior = 'auto'; + element.scrollLeft = targetSlide.offsetLeft; + + // Restore smooth scroll after the jump + setTimeout(() => { + element.style.scrollBehavior = originalScrollBehavior; + }, 50); + } else { + // Use native CSS smooth scrolling for subsequent clicks + element.scrollLeft = targetSlide.offsetLeft; + } } - }, 100); + }; + + if (isInitial) { + // Execute immediately for the first render to prevent flash + scrollAction(); + } else { + // Allow DOM to update first for subsequent clicks + setTimeout(scrollAction, 100); + } } }); @@ -72,10 +102,39 @@ export default class PlacePhotosCarousel extends Component { resizeObserver.observe(element); } + let intersectionObserver; + if (this.args.onVisiblePhotoChange && window.IntersectionObserver) { + // Set up intersection observer to track which photo is currently "most" visible + intersectionObserver = new IntersectionObserver( + (entries) => { + for (let entry of entries) { + if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { + const eventId = entry.target.dataset.eventId; + this.internalEventId = eventId; + const photo = this.photos.find((p) => p.eventId === eventId); + if (photo) { + this.args.onVisiblePhotoChange(photo); + } + } + } + }, + { + root: element, + threshold: 0.5, + } + ); + + const slides = element.querySelectorAll('.carousel-slide'); + slides.forEach((slide) => intersectionObserver.observe(slide)); + } + return () => { if (resizeObserver) { resizeObserver.unobserve(element); } + if (intersectionObserver) { + intersectionObserver.disconnect(); + } }; }); @@ -108,22 +167,17 @@ export default class PlacePhotosCarousel extends Component { } @action - openGallery(photo) { - this.selectedPhoto = photo; - this.isGalleryOpen = true; - } - - @action - closeGallery() { - this.isGalleryOpen = false; - this.selectedPhoto = null; + handlePhotoClick(photo) { + if (this.args.onPhotoClick) { + this.args.onPhotoClick(photo); + } } } diff --git a/app/components/photo-gallery.gjs b/app/components/photo-gallery.gjs new file mode 100644 index 0000000..1cb1909 --- /dev/null +++ b/app/components/photo-gallery.gjs @@ -0,0 +1,85 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import Icon from './icon'; +import PhotoCarousel from './photo-carousel'; + +export default class PhotoGallery extends Component { + @tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0]; + + @action + handleClose() { + if (this.args.onClose) { + this.args.onClose(); + } + } + + @action + handleBackgroundClick(e) { + // Don't close if clicking on thumbnails, nav buttons, or the close button itself + if ( + e.target.closest('.thumbnail-strip-container') || + e.target.closest('.carousel-nav-btn') || + e.target.closest('.close-btn') + ) { + return; + } + + this.handleClose(); + } + + @action + selectPhoto(photo) { + this.currentPhoto = photo; + } + + @action + handleVisiblePhotoChange(photo) { + if (this.currentPhoto !== photo) { + this.currentPhoto = photo; + } + } + + +} diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 3dd602d..e1d8157 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager'; import PlacePhotoUpload from './place-photo-upload'; import NostrConnect from './nostr-connect'; import Modal from './modal'; -import PlacePhotosCarousel from './place-photos-carousel'; +import PhotoCarousel from './photo-carousel'; +import PhotoGallery from './photo-gallery'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -24,15 +25,10 @@ export default class PlaceDetails extends Component { @service nostrData; @tracked isEditing = false; @tracked showLists = false; - @tracked isPhotoUploadModalOpen = false; - @tracked isNostrConnectModalOpen = false; - @tracked newlyUploadedPhotoId = null; @tracked isPhotoUploadActive = false; - - @action - handleUploadStateChange(isActive) { - this.isPhotoUploadActive = isActive; - } + @tracked isConnectingNostr = false; + @tracked isGalleryOpen = false; + @tracked selectedGalleryPhoto = null; @action openPhotoUploadModal(e) { @@ -362,6 +358,18 @@ export default class PlaceDetails extends Component { return !!this.place.description; } + @action + openGallery(photo) { + this.selectedGalleryPhoto = photo; + this.isGalleryOpen = true; + } + + @action + closeGallery() { + this.isGalleryOpen = false; + this.selectedGalleryPhoto = null; + } + } diff --git a/app/modifiers/fade-in-image.js b/app/modifiers/fade-in-image.js index 119b415..4c18f59 100644 --- a/app/modifiers/fade-in-image.js +++ b/app/modifiers/fade-in-image.js @@ -9,11 +9,28 @@ export default modifier((element, [url]) => { let observer; + const hideBlurhash = () => { + const parent = element.parentElement; + const slide = + parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent; + + // Only hide the blurhash if we're in the gallery-main view. + // In the inline view, we want to keep the blurhash visible behind portrait photos + // to fill the 16:9 container gracefully. + if (slide && slide.closest('.photo-carousel.gallery-main')) { + const blur = slide.querySelector('.place-header-photo-blur'); + if (blur) { + blur.style.opacity = '0'; + } + } + }; + const handleLoad = () => { // Only apply the fade-in animation if it wasn't already loaded instantly if (!element.classList.contains('loaded-instant')) { element.classList.add('loaded'); } + hideBlurhash(); }; element.addEventListener('load', handleLoad); @@ -33,6 +50,7 @@ export default modifier((element, [url]) => { if (img.complete) { // Already in browser cache, skip the animation element.classList.add('loaded-instant'); + hideBlurhash(); } // If this image is inside a tag, we also need to swap tags diff --git a/app/styles/app.css b/app/styles/app.css index 1df9504..7e4dde2 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -890,12 +890,15 @@ abbr[title] { padding-bottom: 2rem; } -.place-photos-carousel-wrapper { +.photo-carousel { position: relative; +} + +.photo-carousel.inline { margin: -1rem -1rem 1rem; } -.place-photos-carousel-track { +.photo-carousel-track { display: flex; overflow-x: auto; scroll-behavior: smooth; @@ -904,7 +907,7 @@ abbr[title] { background-color: var(--hover-bg); } -.place-photos-carousel-track::-webkit-scrollbar { +.photo-carousel-track::-webkit-scrollbar { display: none; /* Safari and Chrome */ } @@ -985,7 +988,7 @@ abbr[title] { padding: 0; } -.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) { +.photo-carousel:hover .carousel-nav-btn:not(.disabled) { opacity: 1; } @@ -1007,40 +1010,40 @@ abbr[title] { } @media (width <= 768px) { - .place-photos-carousel-track { + .photo-carousel.inline .photo-carousel-track { scroll-snap-type: none; gap: 2px; background-color: #fff; } - .carousel-slide { + .photo-carousel.inline .carousel-slide { flex: 0 0 auto; height: 100px; width: auto; scroll-snap-align: none; } - .carousel-slide.landscape { + .photo-carousel.inline .carousel-slide.landscape { aspect-ratio: var(--slide-ratio, 16 / 9); } - .carousel-slide.portrait { + .photo-carousel.inline .carousel-slide.portrait { aspect-ratio: 1 / 1; } - .carousel-placeholder { + .photo-carousel.inline .carousel-placeholder { display: block; background-color: var(--hover-bg); flex: 1 1 0%; min-width: 0; } - .place-header-photo.landscape, - .place-header-photo.portrait { + .photo-carousel.inline .place-header-photo.landscape, + .photo-carousel.inline .place-header-photo.portrait { object-fit: cover; } - .carousel-nav-btn { + .photo-carousel.inline .carousel-nav-btn { display: none; } } @@ -1872,7 +1875,7 @@ button.create-place { .photo-gallery-overlay { position: fixed; inset: 0; - background: rgb(0 0 0 / 80%); + background: rgb(0 0 0 / 90%); z-index: 9999; display: flex; flex-direction: column; @@ -1917,30 +1920,99 @@ button.create-place { overflow: hidden; } +@media (width <= 768px) { + .photo-gallery-overlay .main-photo-container { + padding: 2rem 0; + } +} + .photo-gallery-overlay .main-photo-container img { max-width: 100%; max-height: 100%; object-fit: contain; } -.photo-gallery-overlay .thumbnail-strip { - height: 100px; - display: flex; - gap: 0.5rem; - padding: 1rem; - overflow-x: auto; - background: rgb(0 0 0 / 50%); -} - -.photo-gallery-overlay .thumbnail-strip .thumbnail { +/* Photo Carousel: Gallery Main Variant */ +.photo-carousel.gallery-main { + width: 100%; height: 100%; - flex-shrink: 0; - cursor: pointer; + display: flex; + flex-direction: column; } -.photo-gallery-overlay .thumbnail-strip img { +.photo-carousel.gallery-main .photo-carousel-track { + height: 100%; + background: transparent; +} + +.photo-carousel.gallery-main .carousel-slide { + height: 100%; + flex: 0 0 100%; + aspect-ratio: auto; + cursor: default; +} + +.photo-carousel.gallery-main .carousel-nav-btn { + width: 48px; + height: 48px; +} + +.photo-carousel.gallery-main .place-header-photo-blur, +.photo-carousel.gallery-main .place-header-photo.landscape, +.photo-carousel.gallery-main .place-header-photo.portrait { + object-fit: contain; +} + +@media (width <= 768px) { + .photo-carousel.gallery-main .carousel-nav-btn { + display: none; + } +} + +/* Photo Carousel: Gallery Thumbnails Variant */ +.photo-carousel.gallery-thumbnails { + width: 100%; + height: 100px; + background: rgb(0 0 0 / 50%); + padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */ +} + +.photo-carousel.gallery-thumbnails .photo-carousel-track { + height: 100%; + background: transparent; + gap: 4px; + scroll-snap-type: none; + padding: 0; +} + +.photo-carousel.gallery-thumbnails .carousel-slide { + flex: 0 0 auto; height: 100%; width: auto; - object-fit: cover; - border-radius: 4px; + scroll-snap-align: none; + opacity: 0.6; + transition: opacity 0.2s; +} + +.photo-carousel.gallery-thumbnails .carousel-slide.landscape { + aspect-ratio: var(--slide-ratio, 16 / 9); +} + +.photo-carousel.gallery-thumbnails .carousel-slide.portrait { + aspect-ratio: 1 / 1; +} + +.photo-carousel.gallery-thumbnails .carousel-slide:hover, +.photo-carousel.gallery-thumbnails .carousel-slide.active { + opacity: 1; +} + +.photo-carousel.gallery-thumbnails .place-header-photo.landscape, +.photo-carousel.gallery-thumbnails .place-header-photo.portrait { + object-fit: cover; + height: 100%; +} + +.photo-carousel.gallery-thumbnails .carousel-nav-btn { + display: none; } From 0fb320d996fcfa631b95b52ea63a78e569eb6e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 21:20:31 +0100 Subject: [PATCH 3/3] Fix test and linter error --- ...carousel-test.gjs => photo-carousel-test.gjs} | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) rename tests/integration/components/{place-photos-carousel-test.gjs => photo-carousel-test.gjs} (86%) diff --git a/tests/integration/components/place-photos-carousel-test.gjs b/tests/integration/components/photo-carousel-test.gjs similarity index 86% rename from tests/integration/components/place-photos-carousel-test.gjs rename to tests/integration/components/photo-carousel-test.gjs index 167f48e..89e2be4 100644 --- a/tests/integration/components/place-photos-carousel-test.gjs +++ b/tests/integration/components/photo-carousel-test.gjs @@ -1,20 +1,20 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'marco/tests/helpers'; import { render, click } from '@ember/test-helpers'; -import PlacePhotosCarousel from 'marco/components/place-photos-carousel'; +import PhotoCarousel from 'marco/components/photo-carousel'; -module('Integration | Component | place-photos-carousel', function (hooks) { +module('Integration | Component | photo-carousel', function (hooks) { setupRenderingTest(hooks); test('it renders gracefully with no photos', async function (assert) { this.photos = []; await render( - + ); assert - .dom('.place-photos-carousel-wrapper') + .dom('.photo-carousel') .doesNotExist('it does not render the wrapper when there are no photos'); }); @@ -32,14 +32,12 @@ module('Integration | Component | place-photos-carousel', function (hooks) { await render( ); - assert - .dom('.place-photos-carousel-wrapper') - .exists('it renders the wrapper'); + assert.dom('.photo-carousel').exists('it renders the wrapper'); assert .dom('.carousel-slide:not(.carousel-placeholder)') .exists({ count: 1 }, 'it renders one real photo slide'); @@ -84,7 +82,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) { await render( );