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 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}}
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user