Render all place photos in a carousel

This commit is contained in:
2026-04-22 07:32:55 +04:00
parent 85a8699b78
commit 94ba33ecc1
4 changed files with 298 additions and 39 deletions

View File

@@ -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}}
<div class="place-header-photo-wrapper">
{{#if this.headerPhoto.blurhash}}
<Blurhash
@hash={{this.headerPhoto.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
<img
src={{this.headerPhoto.url}}
class="place-header-photo"
alt={{this.name}}
{{fadeInImage this.headerPhoto.url}}
/>
</div>
{{/if}}
<PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} />
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}

View File

@@ -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',
});
}
<template>
{{#if this.photos.length}}
<div class="place-photos-carousel-wrapper">
<div
class="place-photos-carousel-track"
{{this.setupCarousel}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div class="carousel-slide" style={{photo.style}}>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
{{#if photo.isLandscape}}
<picture>
{{#if photo.thumbUrl}}
<source
media="(max-width: 768px)"
srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
/>
</picture>
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
</div>
{{/each}}
</div>
{{#if this.showChevrons}}
<button
type="button"
class="carousel-nav-btn prev
{{if this.cannotScrollLeft 'disabled'}}"
{{on "click" this.scrollLeft}}
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon @name="chevron-left" />
</button>
<button
type="button"
class="carousel-nav-btn next
{{if this.cannotScrollRight 'disabled'}}"
{{on "click" this.scrollRight}}
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon @name="chevron-right" />
</button>
{{/if}}
</div>
{{/if}}
</template>
}