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] 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; }