Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { and, eq } from 'ember-truth-helpers';
|
||||||
import Blurhash from './blurhash';
|
import Blurhash from './blurhash';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import fadeInImage from '../modifiers/fade-in-image';
|
import fadeInImage from '../modifiers/fade-in-image';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
export default class PlacePhotosCarousel extends Component {
|
export default class PhotoCarousel extends Component {
|
||||||
@tracked canScrollLeft = false;
|
@tracked canScrollLeft = false;
|
||||||
@tracked canScrollRight = false;
|
@tracked canScrollRight = false;
|
||||||
|
|
||||||
|
internalEventId = null;
|
||||||
carouselElement = null;
|
carouselElement = null;
|
||||||
|
|
||||||
get photos() {
|
get photos() {
|
||||||
@@ -18,6 +21,7 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showChevrons() {
|
get showChevrons() {
|
||||||
|
// Only show chevrons if there's more than one photo
|
||||||
return this.photos.length > 1;
|
return this.photos.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +33,10 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
return !this.canScrollRight;
|
return !this.canScrollRight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get variantClass() {
|
||||||
|
return this.args.variant || 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
lastResetKey = null;
|
lastResetKey = null;
|
||||||
|
|
||||||
resetScrollPosition = modifier((element, [resetKey]) => {
|
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||||
@@ -41,16 +49,42 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
|
|
||||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||||
if (eventId && eventId !== this.lastEventId) {
|
if (eventId && eventId !== this.lastEventId) {
|
||||||
|
const isInitial = !this.lastEventId;
|
||||||
this.lastEventId = eventId;
|
this.lastEventId = eventId;
|
||||||
// Allow DOM to update first since the photo was *just* added to the store
|
|
||||||
setTimeout(() => {
|
// Prevent feedback loop if this carousel initiated the change
|
||||||
|
if (this.internalEventId === eventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollAction = () => {
|
||||||
const targetSlide = element.querySelector(
|
const targetSlide = element.querySelector(
|
||||||
`[data-event-id="${eventId}"]`
|
`[data-event-id="${eventId}"]`
|
||||||
);
|
);
|
||||||
if (targetSlide) {
|
if (targetSlide) {
|
||||||
element.scrollLeft = targetSlide.offsetLeft;
|
if (isInitial) {
|
||||||
|
const originalScrollBehavior = element.style.scrollBehavior;
|
||||||
|
element.style.scrollBehavior = 'auto';
|
||||||
|
element.scrollLeft = targetSlide.offsetLeft;
|
||||||
|
|
||||||
|
// Restore smooth scroll after the jump
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.scrollBehavior = originalScrollBehavior;
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// Use native CSS smooth scrolling for subsequent clicks
|
||||||
|
element.scrollLeft = targetSlide.offsetLeft;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
};
|
||||||
|
|
||||||
|
if (isInitial) {
|
||||||
|
// Execute immediately for the first render to prevent flash
|
||||||
|
scrollAction();
|
||||||
|
} else {
|
||||||
|
// Allow DOM to update first for subsequent clicks
|
||||||
|
setTimeout(scrollAction, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,10 +102,39 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
resizeObserver.observe(element);
|
resizeObserver.observe(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let intersectionObserver;
|
||||||
|
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
|
||||||
|
// Set up intersection observer to track which photo is currently "most" visible
|
||||||
|
intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||||
|
const eventId = entry.target.dataset.eventId;
|
||||||
|
this.internalEventId = eventId;
|
||||||
|
const photo = this.photos.find((p) => p.eventId === eventId);
|
||||||
|
if (photo) {
|
||||||
|
this.args.onVisiblePhotoChange(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: element,
|
||||||
|
threshold: 0.5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const slides = element.querySelectorAll('.carousel-slide');
|
||||||
|
slides.forEach((slide) => intersectionObserver.observe(slide));
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (resizeObserver) {
|
if (resizeObserver) {
|
||||||
resizeObserver.unobserve(element);
|
resizeObserver.unobserve(element);
|
||||||
}
|
}
|
||||||
|
if (intersectionObserver) {
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,23 +166,35 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handlePhotoClick(photo) {
|
||||||
|
if (this.args.onPhotoClick) {
|
||||||
|
this.args.onPhotoClick(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.photos.length}}
|
{{#if this.photos.length}}
|
||||||
<div class="place-photos-carousel-wrapper">
|
<div class="photo-carousel {{this.variantClass}}">
|
||||||
<div
|
<div
|
||||||
class="place-photos-carousel-track"
|
class="photo-carousel-track"
|
||||||
{{this.setupCarousel}}
|
{{this.setupCarousel}}
|
||||||
{{this.resetScrollPosition @resetKey}}
|
{{this.resetScrollPosition @resetKey}}
|
||||||
{{this.scrollToNewPhoto @scrollToEventId}}
|
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||||
{{on "scroll" this.updateScrollState}}
|
{{on "scroll" this.updateScrollState}}
|
||||||
>
|
>
|
||||||
{{#each this.photos as |photo|}}
|
{{#each this.photos as |photo|}}
|
||||||
{{! template-lint-disable no-inline-styles }}
|
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
|
||||||
<div
|
<div
|
||||||
class="carousel-slide
|
class="carousel-slide
|
||||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
{{if photo.isLandscape 'landscape' 'portrait'}}
|
||||||
|
{{if
|
||||||
|
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
|
||||||
|
'active'
|
||||||
|
}}"
|
||||||
style={{photo.style}}
|
style={{photo.style}}
|
||||||
data-event-id={{photo.eventId}}
|
data-event-id={{photo.eventId}}
|
||||||
|
{{on "click" (fn this.handlePhotoClick photo)}}
|
||||||
>
|
>
|
||||||
{{#if photo.blurhash}}
|
{{#if photo.blurhash}}
|
||||||
<Blurhash
|
<Blurhash
|
||||||
@@ -169,7 +244,11 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
disabled={{this.cannotScrollLeft}}
|
disabled={{this.cannotScrollLeft}}
|
||||||
aria-label="Previous photo"
|
aria-label="Previous photo"
|
||||||
>
|
>
|
||||||
<Icon @name="chevron-left" @color="currentColor" />
|
<Icon
|
||||||
|
@name="chevron-left"
|
||||||
|
@color="currentColor"
|
||||||
|
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -180,7 +259,11 @@ export default class PlacePhotosCarousel extends Component {
|
|||||||
disabled={{this.cannotScrollRight}}
|
disabled={{this.cannotScrollRight}}
|
||||||
aria-label="Next photo"
|
aria-label="Next photo"
|
||||||
>
|
>
|
||||||
<Icon @name="chevron-right" @color="currentColor" />
|
<Icon
|
||||||
|
@name="chevron-right"
|
||||||
|
@color="currentColor"
|
||||||
|
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
85
app/components/photo-gallery.gjs
Normal file
85
app/components/photo-gallery.gjs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from './icon';
|
||||||
|
import PhotoCarousel from './photo-carousel';
|
||||||
|
|
||||||
|
export default class PhotoGallery extends Component {
|
||||||
|
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleClose() {
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleBackgroundClick(e) {
|
||||||
|
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
|
||||||
|
if (
|
||||||
|
e.target.closest('.thumbnail-strip-container') ||
|
||||||
|
e.target.closest('.carousel-nav-btn') ||
|
||||||
|
e.target.closest('.close-btn')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectPhoto(photo) {
|
||||||
|
this.currentPhoto = photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleVisiblePhotoChange(photo) {
|
||||||
|
if (this.currentPhoto !== photo) {
|
||||||
|
this.currentPhoto = photo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="photo-gallery-overlay"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
{{on "click" this.handleBackgroundClick}}
|
||||||
|
>
|
||||||
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
|
<div class="photo-gallery-content">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-btn btn-text"
|
||||||
|
{{on "click" this.handleClose}}
|
||||||
|
aria-label="Close gallery"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} @color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="main-photo-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-main"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||||
|
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thumbnail-strip-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-thumbnails"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||||
|
@onPhotoClick={{this.selectPhoto}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ 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 PlacePhotosCarousel from './place-photos-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
|
import PhotoGallery from './photo-gallery';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -24,15 +25,10 @@ export default class PlaceDetails extends Component {
|
|||||||
@service nostrData;
|
@service nostrData;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
@tracked isPhotoUploadModalOpen = false;
|
|
||||||
@tracked isNostrConnectModalOpen = false;
|
|
||||||
@tracked newlyUploadedPhotoId = null;
|
|
||||||
@tracked isPhotoUploadActive = false;
|
@tracked isPhotoUploadActive = false;
|
||||||
|
@tracked isConnectingNostr = false;
|
||||||
@action
|
@tracked isGalleryOpen = false;
|
||||||
handleUploadStateChange(isActive) {
|
@tracked selectedGalleryPhoto = null;
|
||||||
this.isPhotoUploadActive = isActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
openPhotoUploadModal(e) {
|
openPhotoUploadModal(e) {
|
||||||
@@ -362,6 +358,18 @@ export default class PlaceDetails extends Component {
|
|||||||
return !!this.place.description;
|
return !!this.place.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openGallery(photo) {
|
||||||
|
this.selectedGalleryPhoto = photo;
|
||||||
|
this.isGalleryOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeGallery() {
|
||||||
|
this.isGalleryOpen = false;
|
||||||
|
this.selectedGalleryPhoto = null;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
{{#if this.isEditing}}
|
{{#if this.isEditing}}
|
||||||
@@ -371,11 +379,13 @@ export default class PlaceDetails extends Component {
|
|||||||
@onCancel={{this.cancelEditing}}
|
@onCancel={{this.cancelEditing}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
<PlacePhotosCarousel
|
<PhotoCarousel
|
||||||
|
@variant="inline"
|
||||||
@photos={{this.photos}}
|
@photos={{this.photos}}
|
||||||
@name={{this.name}}
|
@name={{this.name}}
|
||||||
@resetKey={{this.place.osmId}}
|
@resetKey={{this.place.osmId}}
|
||||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||||
|
@onPhotoClick={{this.openGallery}}
|
||||||
/>
|
/>
|
||||||
<h3>{{this.name}}</h3>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
@@ -609,5 +619,14 @@ export default class PlaceDetails extends Component {
|
|||||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
</Modal>
|
</Modal>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isGalleryOpen}}
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedGalleryPhoto}}
|
||||||
|
@placeName={{this.name}}
|
||||||
|
@onClose={{this.closeGallery}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
|
|||||||
|
|
||||||
let observer;
|
let observer;
|
||||||
|
|
||||||
|
const hideBlurhash = () => {
|
||||||
|
const parent = element.parentElement;
|
||||||
|
const slide =
|
||||||
|
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
|
||||||
|
|
||||||
|
// Only hide the blurhash if we're in the gallery-main view.
|
||||||
|
// In the inline view, we want to keep the blurhash visible behind portrait photos
|
||||||
|
// to fill the 16:9 container gracefully.
|
||||||
|
if (slide && slide.closest('.photo-carousel.gallery-main')) {
|
||||||
|
const blur = slide.querySelector('.place-header-photo-blur');
|
||||||
|
if (blur) {
|
||||||
|
blur.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
// Only apply the fade-in animation if it wasn't already loaded instantly
|
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||||
if (!element.classList.contains('loaded-instant')) {
|
if (!element.classList.contains('loaded-instant')) {
|
||||||
element.classList.add('loaded');
|
element.classList.add('loaded');
|
||||||
}
|
}
|
||||||
|
hideBlurhash();
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener('load', handleLoad);
|
element.addEventListener('load', handleLoad);
|
||||||
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
|
|||||||
if (img.complete) {
|
if (img.complete) {
|
||||||
// Already in browser cache, skip the animation
|
// Already in browser cache, skip the animation
|
||||||
element.classList.add('loaded-instant');
|
element.classList.add('loaded-instant');
|
||||||
|
hideBlurhash();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||||
|
|||||||
@@ -890,12 +890,15 @@ abbr[title] {
|
|||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-photos-carousel-wrapper {
|
.photo-carousel {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline {
|
||||||
margin: -1rem -1rem 1rem;
|
margin: -1rem -1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-photos-carousel-track {
|
.photo-carousel-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -904,11 +907,12 @@ abbr[title] {
|
|||||||
background-color: var(--hover-bg);
|
background-color: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-photos-carousel-track::-webkit-scrollbar {
|
.photo-carousel-track::-webkit-scrollbar {
|
||||||
display: none; /* Safari and Chrome */
|
display: none; /* Safari and Chrome */
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-slide {
|
.carousel-slide {
|
||||||
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
@@ -984,7 +988,7 @@ abbr[title] {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
|
.photo-carousel:hover .carousel-nav-btn:not(.disabled) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,40 +1010,40 @@ abbr[title] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.place-photos-carousel-track {
|
.photo-carousel.inline .photo-carousel-track {
|
||||||
scroll-snap-type: none;
|
scroll-snap-type: none;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-slide {
|
.photo-carousel.inline .carousel-slide {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
width: auto;
|
width: auto;
|
||||||
scroll-snap-align: none;
|
scroll-snap-align: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-slide.landscape {
|
.photo-carousel.inline .carousel-slide.landscape {
|
||||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-slide.portrait {
|
.photo-carousel.inline .carousel-slide.portrait {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-placeholder {
|
.photo-carousel.inline .carousel-placeholder {
|
||||||
display: block;
|
display: block;
|
||||||
background-color: var(--hover-bg);
|
background-color: var(--hover-bg);
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-header-photo.landscape,
|
.photo-carousel.inline .place-header-photo.landscape,
|
||||||
.place-header-photo.portrait {
|
.photo-carousel.inline .place-header-photo.portrait {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-nav-btn {
|
.photo-carousel.inline .carousel-nav-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1866,3 +1870,149 @@ button.create-place {
|
|||||||
.btn-link:hover {
|
.btn-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo Gallery */
|
||||||
|
.photo-gallery-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 90%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .photo-gallery-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
z-index: 10;
|
||||||
|
color: white;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-gallery-overlay .close-btn {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .main-photo-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-gallery-overlay .main-photo-container {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .main-photo-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Carousel: Gallery Main Variant */
|
||||||
|
.photo-carousel.gallery-main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .photo-carousel-track {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .carousel-slide {
|
||||||
|
height: 100%;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .place-header-photo-blur,
|
||||||
|
.photo-carousel.gallery-main .place-header-photo.landscape,
|
||||||
|
.photo-carousel.gallery-main .place-header-photo.portrait {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Carousel: Gallery Thumbnails Variant */
|
||||||
|
.photo-carousel.gallery-thumbnails {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .photo-carousel-track {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
gap: 4px;
|
||||||
|
scroll-snap-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
scroll-snap-align: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
|
||||||
|
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
|
||||||
|
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
|
||||||
|
object-fit: cover;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render, click } from '@ember/test-helpers';
|
import { render, click } from '@ember/test-helpers';
|
||||||
import PlacePhotosCarousel from 'marco/components/place-photos-carousel';
|
import PhotoCarousel from 'marco/components/photo-carousel';
|
||||||
|
|
||||||
module('Integration | Component | place-photos-carousel', function (hooks) {
|
module('Integration | Component | photo-carousel', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
test('it renders gracefully with no photos', async function (assert) {
|
test('it renders gracefully with no photos', async function (assert) {
|
||||||
this.photos = [];
|
this.photos = [];
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<template><PlacePhotosCarousel @photos={{this.photos}} /></template>
|
<template><PhotoCarousel @photos={{this.photos}} /></template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert
|
assert
|
||||||
.dom('.place-photos-carousel-wrapper')
|
.dom('.photo-carousel')
|
||||||
.doesNotExist('it does not render the wrapper when there are no photos');
|
.doesNotExist('it does not render the wrapper when there are no photos');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,14 +32,12 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
|
|||||||
await render(
|
await render(
|
||||||
<template>
|
<template>
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<PlacePhotosCarousel @photos={{this.photos}} />
|
<PhotoCarousel @photos={{this.photos}} />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert
|
assert.dom('.photo-carousel').exists('it renders the wrapper');
|
||||||
.dom('.place-photos-carousel-wrapper')
|
|
||||||
.exists('it renders the wrapper');
|
|
||||||
assert
|
assert
|
||||||
.dom('.carousel-slide:not(.carousel-placeholder)')
|
.dom('.carousel-slide:not(.carousel-placeholder)')
|
||||||
.exists({ count: 1 }, 'it renders one real photo slide');
|
.exists({ count: 1 }, 'it renders one real photo slide');
|
||||||
@@ -84,7 +82,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
|
|||||||
await render(
|
await render(
|
||||||
<template>
|
<template>
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<PlacePhotosCarousel @photos={{this.photos}} />
|
<PhotoCarousel @photos={{this.photos}} />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
Reference in New Issue
Block a user