Compare commits

...

18 Commits

Author SHA1 Message Date
632efeeab5 1.21.3
All checks were successful
CI / Lint (push) Successful in 34s
CI / Test (push) Successful in 57s
2026-05-08 11:43:57 +02:00
deeea9961f Merge pull request 'Add photo actions, fix portrait photos using thumb URLs' (#54) from feature/photo_actions into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
Reviewed-on: #54
2026-05-05 09:49:25 +00:00
7a109c9ba5 Fix portrait photos using thumb for full size variant
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-05-05 10:16:12 +02:00
10aae3c9b3 Add photo action for copying event ID 2026-05-05 09:56:12 +02:00
b492e2aa89 Add dropdown component, photo actions menu 2026-05-05 09:49:20 +02:00
4c4a53ae42 1.21.2
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 55s
2026-05-05 07:18:37 +02:00
a240a5d199 Serve dev on all IPs 2026-05-05 07:17:30 +02:00
0332cf4c3c Merge pull request 'Hide quick-search pills on low zoom levels' (#53) from ui/pills_on_zoom into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 54s
Reviewed-on: #53
2026-05-05 05:14:54 +00:00
59c447fe1f Hide quick-search pills on low zoom levels
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-05-05 07:10:28 +02:00
1140ecfe41 Add buttons for opening signer app, copying connect link
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 59s
2026-05-05 07:02:08 +02:00
60936ed2f5 1.21.1
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
2026-04-27 21:51:55 +01:00
ca82a029bc Fix app menu section layout 2026-04-27 21:50:47 +01:00
0630aed73d Fix modals 2026-04-27 21:50:36 +01:00
f27a636529 1.21.0
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-27 21:26:23 +01:00
995ae95b09 Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
Reviewed-on: #52
2026-04-27 20:24:12 +00:00
0fb320d996 Fix test and linter error
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 21:20:39 +01:00
4f4ca827b1 Refactor carousel and gallery to share the carousel component
And make the gallery awesome
2026-04-27 21:12:46 +01:00
c1d3f25d50 WIP Add basic photo gallery 2026-04-27 16:45:49 +01:00
25 changed files with 853 additions and 251 deletions

View File

@@ -15,6 +15,7 @@ export default class AppHeaderComponent extends Component {
@service settings; @service settings;
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service mapUi;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = ''; @tracked searchQuery = '';
@@ -22,6 +23,11 @@ export default class AppHeaderComponent extends Component {
return !!this.searchQuery; return !!this.searchQuery;
} }
get showQuickSearch() {
const zoom = this.mapUi.currentZoom ?? 13;
return this.settings.showQuickSearchButtons && zoom >= 12;
}
@action @action
toggleUserMenu() { toggleUserMenu() {
this.isUserMenuOpen = !this.isUserMenuOpen; this.isUserMenuOpen = !this.isUserMenuOpen;
@@ -54,7 +60,7 @@ export default class AppHeaderComponent extends Component {
/> />
</div> </div>
{{#if this.settings.showQuickSearchButtons}} {{#if this.showQuickSearch}}
<div class="header-center {{if this.hasQuery 'searching'}}"> <div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} /> <CategoryChips @onSelect={{this.handleChipSelect}} />
</div> </div>

View File

@@ -15,7 +15,7 @@ export default class AppMenuSettingsApis extends Component {
<Icon @name="server" @size={{20}} /> <Icon @name="server" @size={{20}} />
<span>API Providers</span> <span>API Providers</span>
</summary> </summary>
<div class="details-content"> <div class="details-content form-layout">
<div class="form-group"> <div class="form-group">
<label for="overpass-api">Overpass API Provider</label> <label for="overpass-api">Overpass API Provider</label>
<select <select

View File

@@ -14,7 +14,7 @@ export default class AppMenuSettingsMapUi extends Component {
<Icon @name="map" @size={{20}} /> <Icon @name="map" @size={{20}} />
<span>Map & UI</span> <span>Map & UI</span>
</summary> </summary>
<div class="details-content"> <div class="details-content form-layout">
<div class="form-group"> <div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label> <label for="show-quick-search">Quick search buttons visible</label>
<select <select

View File

@@ -108,7 +108,7 @@ export default class AppMenuSettingsNostr extends Component {
<Icon @name="zap" @size={{20}} /> <Icon @name="zap" @size={{20}} />
<span>Nostr</span> <span>Nostr</span>
</summary> </summary>
<div class="details-content"> <div class="details-content form-layout">
<div class="form-group"> <div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback <label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label> servers</label>

View File

@@ -0,0 +1,53 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
export default class DropdownMenu extends Component {
@tracked isOpen = false;
@action
toggleMenu(e) {
e?.stopPropagation();
this.isOpen = !this.isOpen;
}
@action
closeMenu(e) {
e?.stopPropagation();
this.isOpen = false;
}
get triggerIcon() {
return this.args.triggerIcon || 'more-vertical';
}
<template>
<div class="dropdown-menu-container">
<button
class="dropdown-trigger-btn btn-press"
type="button"
title={{@triggerTitle}}
{{on "click" this.toggleMenu}}
>
<Icon
@name={{this.triggerIcon}}
@size={{@iconSize}}
@color={{@iconColor}}
/>
</button>
{{#if this.isOpen}}
<div class="dropdown-popover {{@popoverClass}}">
{{yield this.closeMenu}}
</div>
<div
class="menu-backdrop"
{{on "click" this.closeMenu}}
role="button"
></div>
{{/if}}
</div>
</template>
}

View File

@@ -284,6 +284,7 @@ export default class MapComponent extends Component {
// Initialize the UI service with the map center // Initialize the UI service with the map center
const initialCenter = toLonLat(view.getCenter()); const initialCenter = toLonLat(view.getCenter());
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]); this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
this.mapUi.updateZoom(view.getZoom());
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', { apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
webfonts: 'data:text/css,', webfonts: 'data:text/css,',
@@ -1046,6 +1047,7 @@ export default class MapComponent extends Component {
const view = this.mapInstance.getView(); const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter()); const center = toLonLat(view.getCenter());
this.mapUi.updateCenter(center[1], center[0]); this.mapUi.updateCenter(center[1], center[0]);
this.mapUi.updateZoom(view.getZoom());
// If in creation mode, update the coordinates in the service AND the URL // If in creation mode, update the coordinates in the service AND the URL
if (this.mapUi.isCreating) { if (this.mapUi.isCreating) {

View File

@@ -41,6 +41,40 @@ export default class NostrConnectComponent extends Component {
} }
} }
@action
async copyConnectUri() {
const text = this.nostrAuth.connectUri;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Fallback copy failed');
}
}
this.toast.show('Connection link copied to clipboard');
} catch (err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy link');
}
}
<template> <template>
<div class="nostr-connect-modal"> <div class="nostr-connect-modal">
<h2>Connect with Nostr</h2> <h2>Connect with Nostr</h2>
@@ -59,7 +93,7 @@ export default class NostrConnectComponent extends Component {
class="btn btn-outline" class="btn btn-outline"
type="button" type="button"
disabled disabled
title="No Nostr extension found in your browser." title="No Nostr extension found in your browser"
> >
Browser Extension (Not Found) Browser Extension (Not Found)
</button> </button>
@@ -79,9 +113,20 @@ export default class NostrConnectComponent extends Component {
{{#if this.nostrAuth.isMobile}} {{#if this.nostrAuth.isMobile}}
<p>Waiting for you to approve the connection in your mobile signer <p>Waiting for you to approve the connection in your mobile signer
app...</p> app...</p>
<div class="mobile-connect-actions">
<a href={{this.nostrAuth.connectUri}} class="btn btn-primary">
Open Signer App
</a>
<button
class="btn btn-outline"
type="button"
{{on "click" this.copyConnectUri}}
>
Copy Connection Link
</button>
</div>
{{else}} {{else}}
<p>Scan this QR code with a compatible Nostr signer app (like <p>Scan this QR code with a Nostr signer app (like Amber):</p>
Amber):</p>
<div class="qr-code-container"> <div class="qr-code-container">
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas> <canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
</div> </div>

View File

@@ -0,0 +1,298 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { and, eq } from 'ember-truth-helpers';
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 PhotoCarousel extends Component {
@tracked canScrollLeft = false;
@tracked canScrollRight = false;
internalEventId = null;
carouselElement = null;
get photos() {
return this.args.photos || [];
}
get showChevrons() {
// Only show chevrons if there's more than one photo
return this.photos.length > 1;
}
get cannotScrollLeft() {
return !this.canScrollLeft;
}
get cannotScrollRight() {
return !this.canScrollRight;
}
get isGalleryMain() {
return this.args.variant === 'gallery-main';
}
get isGalleryThumbnails() {
return this.args.variant === 'gallery-thumbnails';
}
get variantClass() {
return this.args.variant || 'inline';
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
this.lastEventId = eventId;
// Prevent feedback loop if this carousel initiated the change
if (this.internalEventId === eventId) {
return;
}
const scrollAction = () => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
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;
}
}
};
if (isInitial) {
// Execute immediately for the first render to prevent flash
scrollAction();
} else {
// Allow DOM to update first for subsequent clicks
setTimeout(scrollAction, 100);
}
}
});
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);
}
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 () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
}
if (intersectionObserver) {
intersectionObserver.disconnect();
}
};
});
@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',
});
}
@action
handlePhotoClick(photo) {
if (this.args.onPhotoClick) {
this.args.onPhotoClick(photo);
}
}
<template>
{{#if this.photos.length}}
<div class="photo-carousel {{this.variantClass}}">
<div
class="photo-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
<div
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}
{{if
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
'active'
}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
{{on "click" (fn this.handlePhotoClick photo)}}
>
{{#if photo.blurhash}}
<Blurhash
@hash={{photo.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
{{#if this.isGalleryMain}}
<img
data-src={{photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage photo.url}}
/>
{{else if this.isGalleryThumbnails}}
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{else}}
{{#if photo.isLandscape}}
<picture>
{{#if photo.thumbUrl}}
<source
media="(max-width: 768px)"
data-srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
/>
</picture>
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
{{/if}}
</div>
{{/each}}
<div class="carousel-placeholder"></div>
</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"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</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"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -0,0 +1,124 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import PhotoCarousel from './photo-carousel';
import DropdownMenu from '#components/dropdown-menu';
export default class PhotoGallery extends Component {
@service toast;
@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') ||
e.target.closest('.actions-btn-container')
) {
return;
}
this.handleClose();
}
@action
selectPhoto(photo) {
this.currentPhoto = photo;
}
@action
handleVisiblePhotoChange(photo) {
if (this.currentPhoto !== photo) {
this.currentPhoto = photo;
}
}
@action
async copyEventId(closeMenu) {
if (this.currentPhoto?.eventId) {
try {
await navigator.clipboard.writeText(this.currentPhoto.eventId);
this.toast.show('Event ID copied to clipboard');
} catch (err) {
console.error('Failed to copy event ID:', err);
this.toast.show('Failed to copy event ID');
}
}
closeMenu();
}
<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">
<div class="actions-btn-container">
<DropdownMenu
@iconSize={{24}}
@triggerIcon="more-horizontal"
@iconColor="white"
as |closeMenu|
>
<button
class="dropdown-item"
type="button"
{{on "click" (fn this.copyEventId closeMenu)}}
>Copy Photo Event ID</button>
<button
class="dropdown-item"
type="button"
{{on "click" closeMenu}}
>Report Photo</button>
</DropdownMenu>
</div>
<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>
}

View File

@@ -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,10 +25,13 @@ export default class PlaceDetails extends Component {
@service nostrData; @service nostrData;
@tracked isEditing = false; @tracked isEditing = false;
@tracked showLists = false; @tracked showLists = false;
@tracked isPhotoUploadActive = false;
@tracked isConnectingNostr = false;
@tracked isGalleryOpen = false;
@tracked selectedGalleryPhoto = null;
@tracked isPhotoUploadModalOpen = false; @tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false; @tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null; @tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false;
@action @action
handleUploadStateChange(isActive) { handleUploadStateChange(isActive) {
@@ -362,6 +366,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 +387,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 +627,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>
} }

View File

@@ -1,189 +0,0 @@
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;
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
this.lastResetKey = resetKey;
element.scrollLeft = 0;
setTimeout(() => this.updateScrollState(), 50);
}
});
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
this.lastEventId = eventId;
// Allow DOM to update first since the photo was *just* added to the store
setTimeout(() => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
element.scrollLeft = targetSlide.offsetLeft;
}
}, 100);
}
});
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}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
>
{{#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)"
data-srcset={{photo.thumbUrl}}
/>
{{/if}}
<img
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
{{fadeInImage photo.url}}
/>
</picture>
{{else}}
{{! Portrait uses thumb everywhere if available }}
<img
data-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 class="carousel-placeholder"></div>
</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" @color="currentColor" />
</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" @color="currentColor" />
</button>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -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

View File

@@ -11,6 +11,7 @@ export default class MapUiService extends Service {
@tracked returnToSearch = false; @tracked returnToSearch = false;
@tracked currentCenter = null; @tracked currentCenter = null;
@tracked currentBounds = null; @tracked currentBounds = null;
@tracked currentZoom = null;
@tracked searchBoxHasFocus = false; @tracked searchBoxHasFocus = false;
@tracked selectionOptions = {}; @tracked selectionOptions = {};
@tracked preventNextZoom = false; @tracked preventNextZoom = false;
@@ -81,6 +82,10 @@ export default class MapUiService extends Service {
this.currentCenter = { lat, lon }; this.currentCenter = { lat, lon };
} }
updateZoom(zoom) {
this.currentZoom = zoom;
}
updateBounds(bounds) { updateBounds(bounds) {
this.currentBounds = bounds; this.currentBounds = bounds;
} }

View File

@@ -594,6 +594,9 @@ body {
padding: 0 1.4rem 1rem; padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out; animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem; font-size: 0.9rem;
}
.sidebar-content details .details-content.form-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -890,12 +893,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 +910,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 +991,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 +1013,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;
} }
} }
@@ -1077,6 +1084,7 @@ abbr[title] {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
text-decoration: none;
} }
.btn:disabled { .btn:disabled {
@@ -1773,6 +1781,13 @@ button.create-place {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.mobile-connect-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.nostr-connect-status { .nostr-connect-status {
margin-top: 1.5rem; margin-top: 1.5rem;
text-align: center; text-align: center;
@@ -1866,3 +1881,209 @@ 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;
}
/* Dropdown Menu Component */
.dropdown-menu-container {
position: relative;
display: inline-block;
}
.dropdown-trigger-btn {
background: transparent;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dropdown-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 5px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 0.5rem 0;
z-index: 3001;
min-width: 150px;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropdown-item {
background: transparent;
border: none;
padding: 0.5rem 1rem;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
color: #333;
white-space: nowrap;
}
.dropdown-item:hover {
background: #f0f0f0;
}
/* Actions button in photo gallery */
.photo-gallery-overlay .actions-btn-container {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 48px;
height: 48px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -22,6 +22,8 @@ import mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw'; import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw'; import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw'; import menu from 'feather-icons/dist/icons/menu.svg?raw';
import moreHorizontal from 'feather-icons/dist/icons/more-horizontal.svg?raw';
import moreVertical from 'feather-icons/dist/icons/more-vertical.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw'; import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw'; import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw'; import plus from 'feather-icons/dist/icons/plus.svg?raw';
@@ -81,10 +83,6 @@ import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw'; import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw'; import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw'; import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw'; import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw'; import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw'; import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -193,11 +191,9 @@ const ICONS = {
mail, mail,
map, map,
'map-pin': mapPin, 'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu, menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna, 'more-horizontal': moreHorizontal,
'molar-tooth': molarTooth, 'more-vertical': moreVertical,
navigation, navigation,
'needle-and-spool-of-thread': needleAndSpoolOfThread, 'needle-and-spool-of-thread': needleAndSpoolOfThread,
nostrich, nostrich,

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.20.5", "version": "1.21.3",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-Dhq0XoTm.js"></script> <script type="module" crossorigin src="/assets/main-C_1D7C3-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css"> <link rel="stylesheet" crossorigin href="/assets/main-BmLeTC2Y.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -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>
); );

View File

@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
export default defineConfig({ export default defineConfig({
server: { server: {
host: '127.0.0.1', host: '0.0.0.0',
}, },
plugins: [ plugins: [
ember(), ember(),