From 94ba33ecc1bdda45272fb4d21dbd26a12fcb6643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 22 Apr 2026 07:32:55 +0400 Subject: [PATCH] Render all place photos in a carousel --- app/components/place-details.gjs | 70 +++++----- app/components/place-photos-carousel.gjs | 155 +++++++++++++++++++++++ app/styles/app.css | 108 +++++++++++++++- app/utils/icons.js | 4 + 4 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 app/components/place-photos-carousel.gjs diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 662fa30..daa889d 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -12,8 +12,7 @@ import PlaceListsManager from './place-lists-manager'; import PlacePhotoUpload from './place-photo-upload'; import NostrConnect from './nostr-connect'; import Modal from './modal'; -import Blurhash from './blurhash'; -import fadeInImage from '../modifiers/fade-in-image'; +import PlacePhotosCarousel from './place-photos-carousel'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -79,51 +78,71 @@ export default class PlaceDetails extends Component { return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place'; } - get headerPhoto() { - const photos = this.nostrData.placePhotos; - if (!photos || photos.length === 0) return null; + get photos() { + const rawPhotos = this.nostrData.placePhotos; + if (!rawPhotos || rawPhotos.length === 0) return []; // Sort by created_at ascending (oldest first) - const sortedEvents = [...photos].sort( + const sortedEvents = [...rawPhotos].sort( (a, b) => a.created_at - b.created_at ); - let firstPortrait = null; + const allPhotos = []; for (const event of sortedEvents) { // Find all imeta tags const imetas = event.tags.filter((t) => t[0] === 'imeta'); for (const imeta of imetas) { let url = null; + let thumbUrl = null; let blurhash = null; let isLandscape = false; + let aspectRatio = 16 / 9; // default for (const tag of imeta.slice(1)) { if (tag.startsWith('url ')) { url = tag.substring(4); + } else if (tag.startsWith('thumb ')) { + thumbUrl = tag.substring(6); } else if (tag.startsWith('blurhash ')) { blurhash = tag.substring(9); } else if (tag.startsWith('dim ')) { const dimStr = tag.substring(4); const [width, height] = dimStr.split('x').map(Number); - if (width && height && width > height) { - isLandscape = true; + if (width && height) { + aspectRatio = width / height; + if (width > height) { + isLandscape = true; + } } } } if (url) { - const photoData = { url, blurhash }; - if (isLandscape) { - return photoData; // Return the first landscape photo found - } else if (!firstPortrait) { - firstPortrait = photoData; // Save the first portrait as fallback - } + allPhotos.push({ + url, + thumbUrl, + blurhash, + isLandscape, + aspectRatio, + style: htmlSafe(`--slide-ratio: ${aspectRatio};`), + }); } } } - return firstPortrait; + if (allPhotos.length === 0) return []; + + // Find the first landscape photo + const firstLandscapeIndex = allPhotos.findIndex((p) => p.isLandscape); + + if (firstLandscapeIndex > 0) { + // Move the first landscape photo to the front + const [firstLandscape] = allPhotos.splice(firstLandscapeIndex, 1); + allPhotos.unshift(firstLandscape); + } + + return allPhotos; } @action @@ -389,24 +408,7 @@ export default class PlaceDetails extends Component { @onCancel={{this.cancelEditing}} /> {{else}} - {{#if this.headerPhoto}} -
- {{#if this.headerPhoto.blurhash}} - - {{/if}} - {{this.name}} -
- {{/if}} +

{{this.name}}

{{this.type}} diff --git a/app/components/place-photos-carousel.gjs b/app/components/place-photos-carousel.gjs new file mode 100644 index 0000000..942ee05 --- /dev/null +++ b/app/components/place-photos-carousel.gjs @@ -0,0 +1,155 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import Blurhash from './blurhash'; +import Icon from './icon'; +import fadeInImage from '../modifiers/fade-in-image'; +import { on } from '@ember/modifier'; +import { modifier } from 'ember-modifier'; + +export default class PlacePhotosCarousel extends Component { + @tracked canScrollLeft = false; + @tracked canScrollRight = false; + + carouselElement = null; + + get photos() { + return this.args.photos || []; + } + + get showChevrons() { + return this.photos.length > 1; + } + + get cannotScrollLeft() { + return !this.canScrollLeft; + } + + get cannotScrollRight() { + return !this.canScrollRight; + } + + setupCarousel = modifier((element) => { + this.carouselElement = element; + + // Defer the initial calculation slightly to ensure CSS and images have applied + setTimeout(() => { + this.updateScrollState(); + }, 50); + + let resizeObserver; + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(() => this.updateScrollState()); + resizeObserver.observe(element); + } + + return () => { + if (resizeObserver) { + resizeObserver.unobserve(element); + } + }; + }); + + @action + updateScrollState() { + if (!this.carouselElement) return; + + const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement; + // tolerance of 1px for floating point rounding issues + this.canScrollLeft = scrollLeft > 1; + this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1; + } + + @action + scrollLeft() { + if (!this.carouselElement) return; + this.carouselElement.scrollBy({ + left: -this.carouselElement.clientWidth, + behavior: 'smooth', + }); + } + + @action + scrollRight() { + if (!this.carouselElement) return; + this.carouselElement.scrollBy({ + left: this.carouselElement.clientWidth, + behavior: 'smooth', + }); + } + + +} diff --git a/app/styles/app.css b/app/styles/app.css index b1063a7..f5db7a4 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -860,12 +860,33 @@ abbr[title] { padding-bottom: 2rem; } -.place-header-photo-wrapper { - margin: -1rem -1rem 1rem; - background-color: var(--hover-bg); +.place-photos-carousel-wrapper { position: relative; - overflow: hidden; + margin: -1rem -1rem 1rem; +} + +.place-photos-carousel-track { + display: flex; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scrollbar-width: none; /* Firefox */ + background-color: var(--hover-bg); +} + +.place-photos-carousel-track::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.carousel-slide { + position: relative; + flex: 0 0 100%; + scroll-snap-align: start; aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; } .place-header-photo-blur { @@ -884,10 +905,18 @@ abbr[title] { left: 0; width: 100%; height: 100%; - object-fit: cover; display: block; opacity: 0; transition: opacity 0.3s ease-in-out; + z-index: 1; /* Stay above blurhash */ +} + +.place-header-photo.landscape { + object-fit: cover; +} + +.place-header-photo.portrait { + object-fit: contain; } .place-header-photo.loaded { @@ -899,6 +928,75 @@ abbr[title] { transition: none; } +.carousel-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgb(0 0 0 / 50%); + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + opacity: 0; + transition: + opacity 0.2s, + background-color 0.2s; + padding: 0; +} + +.place-photos-carousel-wrapper:hover .carousel-nav-btn { + opacity: 1; +} + +.carousel-nav-btn:hover { + background: rgb(0 0 0 / 80%); +} + +.carousel-nav-btn.disabled { + opacity: 0; + pointer-events: none; +} + +.carousel-nav-btn.prev { + left: 0.5rem; +} + +.carousel-nav-btn.next { + right: 0.5rem; +} + +@media (width <= 768px) { + .place-photos-carousel-track { + scroll-snap-type: none; /* No snapping on mobile */ + gap: 0.25rem; + padding-bottom: 0.5rem; /* Space for the scrollbar if visible, but we hid it */ + } + + .carousel-slide { + flex: 0 0 auto; + height: 100px; + width: calc(100px * var(--slide-ratio, 1.7778)); + aspect-ratio: auto; + scroll-snap-align: none; + } + + .place-header-photo.landscape, + .place-header-photo.portrait { + /* On mobile, all images use cover inside their precise ratio container */ + object-fit: cover; + } + + .carousel-nav-btn { + display: none; + } +} + .place-details h3 { font-size: 1.2rem; margin-top: 0; diff --git a/app/utils/icons.js b/app/utils/icons.js index f2b3029..4f24d68 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -4,6 +4,8 @@ import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw'; import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw'; import camera from 'feather-icons/dist/icons/camera.svg?raw'; import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw'; +import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw'; +import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw'; import clock from 'feather-icons/dist/icons/clock.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; @@ -132,6 +134,8 @@ const ICONS = { bus, camera, 'check-square': checkSquare, + 'chevron-left': chevronLeft, + 'chevron-right': chevronRight, 'cigarette-with-smoke-curl': cigaretteWithSmokeCurl, climbing_wall: climbingWall, check,