Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s

Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
2026-04-27 20:24:12 +00:00
6 changed files with 395 additions and 42 deletions

View File

@@ -1,16 +1,19 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { and, eq } from 'ember-truth-helpers';
import Blurhash from './blurhash'; import Blurhash from './blurhash';
import Icon from './icon'; import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image'; import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier'; import { modifier } from 'ember-modifier';
export default class PlacePhotosCarousel extends Component { export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false; @tracked canScrollLeft = false;
@tracked canScrollRight = false; @tracked canScrollRight = false;
internalEventId = null;
carouselElement = null; carouselElement = null;
get photos() { get photos() {
@@ -18,6 +21,7 @@ export default class PlacePhotosCarousel extends Component {
} }
get showChevrons() { get showChevrons() {
// Only show chevrons if there's more than one photo
return this.photos.length > 1; return this.photos.length > 1;
} }
@@ -29,6 +33,10 @@ export default class PlacePhotosCarousel extends Component {
return !this.canScrollRight; return !this.canScrollRight;
} }
get variantClass() {
return this.args.variant || 'inline';
}
lastResetKey = null; lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => { resetScrollPosition = modifier((element, [resetKey]) => {
@@ -41,16 +49,42 @@ export default class PlacePhotosCarousel extends Component {
scrollToNewPhoto = modifier((element, [eventId]) => { scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) { if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
this.lastEventId = eventId; 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( const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]` `[data-event-id="${eventId}"]`
); );
if (targetSlide) { 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);
}
} }
}); });
@@ -68,10 +102,39 @@ export default class PlacePhotosCarousel extends Component {
resizeObserver.observe(element); 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 () => { return () => {
if (resizeObserver) { if (resizeObserver) {
resizeObserver.unobserve(element); resizeObserver.unobserve(element);
} }
if (intersectionObserver) {
intersectionObserver.disconnect();
}
}; };
}); });
@@ -103,23 +166,35 @@ export default class PlacePhotosCarousel extends Component {
}); });
} }
@action
handlePhotoClick(photo) {
if (this.args.onPhotoClick) {
this.args.onPhotoClick(photo);
}
}
<template> <template>
{{#if this.photos.length}} {{#if this.photos.length}}
<div class="place-photos-carousel-wrapper"> <div class="photo-carousel {{this.variantClass}}">
<div <div
class="place-photos-carousel-track" class="photo-carousel-track"
{{this.setupCarousel}} {{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}} {{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}} {{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}} {{on "scroll" this.updateScrollState}}
> >
{{#each this.photos as |photo|}} {{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }} {{! template-lint-disable no-inline-styles no-invalid-interactive }}
<div <div
class="carousel-slide class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}" {{if photo.isLandscape 'landscape' 'portrait'}}
{{if
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
'active'
}}"
style={{photo.style}} style={{photo.style}}
data-event-id={{photo.eventId}} data-event-id={{photo.eventId}}
{{on "click" (fn this.handlePhotoClick photo)}}
> >
{{#if photo.blurhash}} {{#if photo.blurhash}}
<Blurhash <Blurhash
@@ -169,7 +244,11 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollLeft}} disabled={{this.cannotScrollLeft}}
aria-label="Previous photo" aria-label="Previous photo"
> >
<Icon @name="chevron-left" @color="currentColor" /> <Icon
@name="chevron-left"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button> </button>
<button <button
@@ -180,7 +259,11 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollRight}} disabled={{this.cannotScrollRight}}
aria-label="Next photo" aria-label="Next photo"
> >
<Icon @name="chevron-right" @color="currentColor" /> <Icon
@name="chevron-right"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button> </button>
{{/if}} {{/if}}
</div> </div>

View File

@@ -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;
}
}
<template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.handleBackgroundClick}}
>
{{! template-lint-disable no-invalid-interactive }}
<div class="photo-gallery-content">
<button
type="button"
class="close-btn btn-text"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onPhotoClick={{this.selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
</template>
}

View File

@@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload'; import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect'; import NostrConnect from './nostr-connect';
import Modal from './modal'; 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 { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -24,15 +25,10 @@ export default class PlaceDetails extends Component {
@service nostrData; @service nostrData;
@tracked isEditing = false; @tracked isEditing = false;
@tracked showLists = false; @tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false; @tracked isPhotoUploadActive = false;
@tracked isConnectingNostr = false;
@action @tracked isGalleryOpen = false;
handleUploadStateChange(isActive) { @tracked selectedGalleryPhoto = null;
this.isPhotoUploadActive = isActive;
}
@action @action
openPhotoUploadModal(e) { openPhotoUploadModal(e) {
@@ -362,6 +358,18 @@ export default class PlaceDetails extends Component {
return !!this.place.description; return !!this.place.description;
} }
@action
openGallery(photo) {
this.selectedGalleryPhoto = photo;
this.isGalleryOpen = true;
}
@action
closeGallery() {
this.isGalleryOpen = false;
this.selectedGalleryPhoto = null;
}
<template> <template>
<div class="place-details"> <div class="place-details">
{{#if this.isEditing}} {{#if this.isEditing}}
@@ -371,11 +379,13 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}} @onCancel={{this.cancelEditing}}
/> />
{{else}} {{else}}
<PlacePhotosCarousel <PhotoCarousel
@variant="inline"
@photos={{this.photos}} @photos={{this.photos}}
@name={{this.name}} @name={{this.name}}
@resetKey={{this.place.osmId}} @resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}} @scrollToEventId={{this.newlyUploadedPhotoId}}
@onPhotoClick={{this.openGallery}}
/> />
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
@@ -609,5 +619,14 @@ export default class PlaceDetails extends Component {
<NostrConnect @onConnect={{this.onNostrConnected}} /> <NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal> </Modal>
{{/if}} {{/if}}
{{#if this.isGalleryOpen}}
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedGalleryPhoto}}
@placeName={{this.name}}
@onClose={{this.closeGallery}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
let observer; 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 = () => { const handleLoad = () => {
// Only apply the fade-in animation if it wasn't already loaded instantly // Only apply the fade-in animation if it wasn't already loaded instantly
if (!element.classList.contains('loaded-instant')) { if (!element.classList.contains('loaded-instant')) {
element.classList.add('loaded'); element.classList.add('loaded');
} }
hideBlurhash();
}; };
element.addEventListener('load', handleLoad); element.addEventListener('load', handleLoad);
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
if (img.complete) { if (img.complete) {
// Already in browser cache, skip the animation // Already in browser cache, skip the animation
element.classList.add('loaded-instant'); element.classList.add('loaded-instant');
hideBlurhash();
} }
// If this image is inside a <picture> tag, we also need to swap <source> tags // If this image is inside a <picture> tag, we also need to swap <source> tags

View File

@@ -890,12 +890,15 @@ abbr[title] {
padding-bottom: 2rem; padding-bottom: 2rem;
} }
.place-photos-carousel-wrapper { .photo-carousel {
position: relative; position: relative;
}
.photo-carousel.inline {
margin: -1rem -1rem 1rem; margin: -1rem -1rem 1rem;
} }
.place-photos-carousel-track { .photo-carousel-track {
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -904,11 +907,12 @@ abbr[title] {
background-color: var(--hover-bg); background-color: var(--hover-bg);
} }
.place-photos-carousel-track::-webkit-scrollbar { .photo-carousel-track::-webkit-scrollbar {
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
.carousel-slide { .carousel-slide {
cursor: pointer;
position: relative; position: relative;
flex: 0 0 100%; flex: 0 0 100%;
scroll-snap-align: start; scroll-snap-align: start;
@@ -984,7 +988,7 @@ abbr[title] {
padding: 0; padding: 0;
} }
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) { .photo-carousel:hover .carousel-nav-btn:not(.disabled) {
opacity: 1; opacity: 1;
} }
@@ -1006,40 +1010,40 @@ abbr[title] {
} }
@media (width <= 768px) { @media (width <= 768px) {
.place-photos-carousel-track { .photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none; scroll-snap-type: none;
gap: 2px; gap: 2px;
background-color: #fff; background-color: #fff;
} }
.carousel-slide { .photo-carousel.inline .carousel-slide {
flex: 0 0 auto; flex: 0 0 auto;
height: 100px; height: 100px;
width: auto; width: auto;
scroll-snap-align: none; scroll-snap-align: none;
} }
.carousel-slide.landscape { .photo-carousel.inline .carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9); aspect-ratio: var(--slide-ratio, 16 / 9);
} }
.carousel-slide.portrait { .photo-carousel.inline .carousel-slide.portrait {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
.carousel-placeholder { .photo-carousel.inline .carousel-placeholder {
display: block; display: block;
background-color: var(--hover-bg); background-color: var(--hover-bg);
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
} }
.place-header-photo.landscape, .photo-carousel.inline .place-header-photo.landscape,
.place-header-photo.portrait { .photo-carousel.inline .place-header-photo.portrait {
object-fit: cover; object-fit: cover;
} }
.carousel-nav-btn { .photo-carousel.inline .carousel-nav-btn {
display: none; display: none;
} }
} }
@@ -1866,3 +1870,149 @@ button.create-place {
.btn-link:hover { .btn-link:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Photo Gallery */
.photo-gallery-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 90%);
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;
}
@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 Carousel: Gallery Main Variant */
.photo-carousel.gallery-main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.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;
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;
}

View File

@@ -1,20 +1,20 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-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); setupRenderingTest(hooks);
test('it renders gracefully with no photos', async function (assert) { test('it renders gracefully with no photos', async function (assert) {
this.photos = []; this.photos = [];
await render( await render(
<template><PlacePhotosCarousel @photos={{this.photos}} /></template> <template><PhotoCarousel @photos={{this.photos}} /></template>
); );
assert assert
.dom('.place-photos-carousel-wrapper') .dom('.photo-carousel')
.doesNotExist('it does not render the wrapper when there are no photos'); .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( await render(
<template> <template>
<div class="test-container"> <div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} /> <PhotoCarousel @photos={{this.photos}} />
</div> </div>
</template> </template>
); );
assert assert.dom('.photo-carousel').exists('it renders the wrapper');
.dom('.place-photos-carousel-wrapper')
.exists('it renders the wrapper');
assert assert
.dom('.carousel-slide:not(.carousel-placeholder)') .dom('.carousel-slide:not(.carousel-placeholder)')
.exists({ count: 1 }, 'it renders one real photo slide'); .exists({ count: 1 }, 'it renders one real photo slide');
@@ -84,7 +82,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
await render( await render(
<template> <template>
<div class="test-container"> <div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} /> <PhotoCarousel @photos={{this.photos}} />
</div> </div>
</template> </template>
); );