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 PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect'; import NostrConnect from './nostr-connect';
import Modal from './modal'; import Modal from './modal';
import Blurhash from './blurhash'; import PlacePhotosCarousel from './place-photos-carousel';
import fadeInImage from '../modifiers/fade-in-image';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -79,51 +78,71 @@ export default class PlaceDetails extends Component {
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place'; return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
} }
get headerPhoto() { get photos() {
const photos = this.nostrData.placePhotos; const rawPhotos = this.nostrData.placePhotos;
if (!photos || photos.length === 0) return null; if (!rawPhotos || rawPhotos.length === 0) return [];
// Sort by created_at ascending (oldest first) // Sort by created_at ascending (oldest first)
const sortedEvents = [...photos].sort( const sortedEvents = [...rawPhotos].sort(
(a, b) => a.created_at - b.created_at (a, b) => a.created_at - b.created_at
); );
let firstPortrait = null; const allPhotos = [];
for (const event of sortedEvents) { for (const event of sortedEvents) {
// Find all imeta tags // Find all imeta tags
const imetas = event.tags.filter((t) => t[0] === 'imeta'); const imetas = event.tags.filter((t) => t[0] === 'imeta');
for (const imeta of imetas) { for (const imeta of imetas) {
let url = null; let url = null;
let thumbUrl = null;
let blurhash = null; let blurhash = null;
let isLandscape = false; let isLandscape = false;
let aspectRatio = 16 / 9; // default
for (const tag of imeta.slice(1)) { for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) { if (tag.startsWith('url ')) {
url = tag.substring(4); url = tag.substring(4);
} else if (tag.startsWith('thumb ')) {
thumbUrl = tag.substring(6);
} else if (tag.startsWith('blurhash ')) { } else if (tag.startsWith('blurhash ')) {
blurhash = tag.substring(9); blurhash = tag.substring(9);
} else if (tag.startsWith('dim ')) { } else if (tag.startsWith('dim ')) {
const dimStr = tag.substring(4); const dimStr = tag.substring(4);
const [width, height] = dimStr.split('x').map(Number); const [width, height] = dimStr.split('x').map(Number);
if (width && height && width > height) { if (width && height) {
isLandscape = true; aspectRatio = width / height;
if (width > height) {
isLandscape = true;
}
} }
} }
} }
if (url) { if (url) {
const photoData = { url, blurhash }; allPhotos.push({
if (isLandscape) { url,
return photoData; // Return the first landscape photo found thumbUrl,
} else if (!firstPortrait) { blurhash,
firstPortrait = photoData; // Save the first portrait as fallback 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 @action
@@ -389,24 +408,7 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}} @onCancel={{this.cancelEditing}}
/> />
{{else}} {{else}}
{{#if this.headerPhoto}} <PlacePhotosCarousel @photos={{this.photos}} @name={{this.name}} />
<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}}
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
{{this.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>
}

View File

@@ -860,12 +860,33 @@ abbr[title] {
padding-bottom: 2rem; padding-bottom: 2rem;
} }
.place-header-photo-wrapper { .place-photos-carousel-wrapper {
margin: -1rem -1rem 1rem;
background-color: var(--hover-bg);
position: relative; 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; aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
} }
.place-header-photo-blur { .place-header-photo-blur {
@@ -884,10 +905,18 @@ abbr[title] {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
display: block; display: block;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease-in-out; 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 { .place-header-photo.loaded {
@@ -899,6 +928,75 @@ abbr[title] {
transition: none; 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 { .place-details h3 {
font-size: 1.2rem; font-size: 1.2rem;
margin-top: 0; margin-top: 0;

View File

@@ -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 bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import camera from 'feather-icons/dist/icons/camera.svg?raw'; import camera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.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 clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
@@ -132,6 +134,8 @@ const ICONS = {
bus, bus,
camera, camera,
'check-square': checkSquare, 'check-square': checkSquare,
'chevron-left': chevronLeft,
'chevron-right': chevronRight,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl, 'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
climbing_wall: climbingWall, climbing_wall: climbingWall,
check, check,