Render all place photos in a carousel
This commit is contained in:
@@ -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}}
|
||||
|
||||
155
app/components/place-photos-carousel.gjs
Normal file
155
app/components/place-photos-carousel.gjs
Normal 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>
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user