Compare commits
11 Commits
v1.20.5
...
0fb320d996
| Author | SHA1 | Date | |
|---|---|---|---|
|
0fb320d996
|
|||
|
4f4ca827b1
|
|||
|
c1d3f25d50
|
|||
|
2087cfc4f7
|
|||
|
8572032481
|
|||
|
b4c3f5c88d
|
|||
|
cff19980d5
|
|||
|
cf251f702b
|
|||
|
d2eb888dcf
|
|||
|
a0b4a4b3f3
|
|||
|
cb3ee48909
|
@@ -782,14 +782,19 @@ export default class MapComponent extends Component {
|
|||||||
// Check if mobile (width <= 768px matches CSS)
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
if (size[0] <= 768) {
|
if (size[0] <= 768) {
|
||||||
// On mobile, the bottom 50% is covered by the sheet.
|
// On mobile, the bottom 50% is covered by the sheet.
|
||||||
// We want the pin to be in the center of the TOP 50% (visible area).
|
// We want the pin to be in the center of the TOP 50% (visible area), minus the header.
|
||||||
// That means the pin should be at y = height * 0.25 (25% down from top).
|
|
||||||
// The map center is at y = height * 0.50.
|
|
||||||
// So the pin is "above" the center by 25% of the height in pixels.
|
|
||||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
|
||||||
|
|
||||||
const height = size[1];
|
const height = size[1];
|
||||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
const headerEl = document.querySelector('.app-header');
|
||||||
|
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||||
|
|
||||||
|
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
|
||||||
|
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
|
||||||
|
|
||||||
|
// The map center is at y = height * 0.50.
|
||||||
|
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
|
||||||
|
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||||
|
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
|
||||||
const offsetMapUnits = offsetPixels * resolution;
|
const offsetMapUnits = offsetPixels * resolution;
|
||||||
|
|
||||||
// Shift center SOUTH (decrease Y).
|
// Shift center SOUTH (decrease Y).
|
||||||
@@ -849,6 +854,9 @@ export default class MapComponent extends Component {
|
|||||||
let targetPixelY = pixel[1];
|
let targetPixelY = pixel[1];
|
||||||
let needsPan = false;
|
let needsPan = false;
|
||||||
|
|
||||||
|
const headerEl = document.querySelector('.app-header');
|
||||||
|
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||||
|
|
||||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||||
if (size[0] <= 768) {
|
if (size[0] <= 768) {
|
||||||
const height = size[1];
|
const height = size[1];
|
||||||
@@ -856,7 +864,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// If in bottom half
|
// If in bottom half
|
||||||
if (pixel[1] > splitPoint) {
|
if (pixel[1] > splitPoint) {
|
||||||
targetPixelY = height * 0.25; // Target: Center of top half
|
targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
|
||||||
needsPan = true;
|
needsPan = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,11 +885,10 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// 3. Header Logic (Any screen size)
|
// 3. Header Logic (Any screen size)
|
||||||
// Check if the (potentially new) target Y is under the header
|
// Check if the (potentially new) target Y is under the header
|
||||||
const headerHeight = 60;
|
const minTopDistance = headerHeight + 20; // Provide some padding
|
||||||
const minTopDistance = headerHeight + 20; // 80px
|
|
||||||
|
|
||||||
if (targetPixelY < minTopDistance) {
|
if (targetPixelY < minTopDistance) {
|
||||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
targetPixelY = minTopDistance + 30; // Move it clear of header
|
||||||
needsPan = true;
|
needsPan = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,6 +1151,8 @@ export default class MapComponent extends Component {
|
|||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
}
|
}
|
||||||
this.mapUi.preventNextZoom = true;
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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;
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { getDistance } from '../utils/geo';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
|
@service osm;
|
||||||
|
@service photon;
|
||||||
|
@service mapUi;
|
||||||
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||||
|
|
||||||
lat = null;
|
lat = null;
|
||||||
@@ -8,4 +18,175 @@ export default class SearchController extends Controller {
|
|||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
category = null;
|
category = null;
|
||||||
|
|
||||||
|
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||||
|
// Hide sidebar and clear previous results immediately to signal a new search
|
||||||
|
this.mapUi.hideSidebar();
|
||||||
|
this.mapUi.clearSearchResults();
|
||||||
|
|
||||||
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
|
let pois = [];
|
||||||
|
let loadingType = null;
|
||||||
|
let loadingValue = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Case 0: Category Search (category parameter present)
|
||||||
|
if (params.category && lat && lon) {
|
||||||
|
loadingType = 'category';
|
||||||
|
loadingValue = params.category;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// We need bounds. If we have active map state, use it.
|
||||||
|
let bounds = this.mapUi.currentBounds;
|
||||||
|
|
||||||
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
|
// or just use a fixed box around the center.
|
||||||
|
if (!bounds) {
|
||||||
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
|
// Let's take a safe box of ~1km radius.
|
||||||
|
const delta = 0.01;
|
||||||
|
bounds = {
|
||||||
|
minLat: lat - delta,
|
||||||
|
maxLat: lat + delta,
|
||||||
|
minLon: lon - delta,
|
||||||
|
maxLon: lon + delta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pois = await this.osm.getCategoryPois(
|
||||||
|
bounds,
|
||||||
|
params.category,
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by distance from center
|
||||||
|
pois = pois
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
// Case 1: Text Search (q parameter present)
|
||||||
|
else if (params.q) {
|
||||||
|
loadingType = 'text';
|
||||||
|
loadingValue = params.q;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
|
// Search local bookmarks by name
|
||||||
|
const queryLower = params.q.toLowerCase();
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
return (
|
||||||
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Case 2: Nearby Search (lat/lon present, no q)
|
||||||
|
else if (lat && lon) {
|
||||||
|
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||||
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
|
// Fetch POIs from Overpass
|
||||||
|
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance from click
|
||||||
|
pois = pois
|
||||||
|
.map((p) => {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search request failed.', error);
|
||||||
|
this.toast.show('Search request failed. Please try again.');
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (loadingType && loadingValue) {
|
||||||
|
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any of these are already bookmarked
|
||||||
|
// We resolve them to the bookmark version if they exist
|
||||||
|
pois = pois.map((p) => {
|
||||||
|
const saved = this.storage.findPlaceById(p.osmId);
|
||||||
|
return saved || p;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetName = params.selected || params.q;
|
||||||
|
|
||||||
|
if (targetName && pois.length > 0) {
|
||||||
|
let matchedPlace = null;
|
||||||
|
|
||||||
|
// 1. Exact Name Match
|
||||||
|
matchedPlace = pois.find(
|
||||||
|
(p) =>
|
||||||
|
p.osmTags &&
|
||||||
|
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||||
|
// Note: MapComponent had logic for <=20m + type match.
|
||||||
|
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||||
|
// For now, let's stick to name or very close proximity.
|
||||||
|
if (!matchedPlace) {
|
||||||
|
const topCandidate = pois[0];
|
||||||
|
if (topCandidate._distance <= 10) {
|
||||||
|
matchedPlace = topCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedPlace) {
|
||||||
|
// Direct transition!
|
||||||
|
this.router.replaceWith('place', matchedPlace);
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mapUi.setSearchResults(pois);
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default class PlaceRoute extends Route {
|
|||||||
if (model) {
|
if (model) {
|
||||||
const options = { preventZoom: this.mapUi.preventNextZoom };
|
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||||
this.mapUi.selectPlace(model, options);
|
this.mapUi.selectPlace(model, options);
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.mapUi.preventNextZoom = false;
|
this.mapUi.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
|
|||||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||||
}
|
}
|
||||||
this.mapUi.startCreating();
|
this.mapUi.startCreating();
|
||||||
|
this.mapUi.showSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate() {
|
deactivate() {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { getDistance } from '../utils/geo';
|
|
||||||
|
|
||||||
export default class SearchRoute extends Route {
|
export default class SearchRoute extends Route {
|
||||||
@service osm;
|
|
||||||
@service photon;
|
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service storage;
|
|
||||||
@service router;
|
|
||||||
@service toast;
|
@service toast;
|
||||||
|
|
||||||
queryParams = {
|
queryParams = {
|
||||||
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
|
|||||||
category: { refreshModel: true },
|
category: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
model(params) {
|
||||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
// Just return params, doing the async fetch in the controller
|
||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
return params;
|
||||||
let pois = [];
|
|
||||||
let loadingType = null;
|
|
||||||
let loadingValue = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Case 0: Category Search (category parameter present)
|
|
||||||
if (params.category && lat && lon) {
|
|
||||||
loadingType = 'category';
|
|
||||||
loadingValue = params.category;
|
|
||||||
this.mapUi.startLoading(loadingType, loadingValue);
|
|
||||||
|
|
||||||
// We need bounds. If we have active map state, use it.
|
|
||||||
let bounds = this.mapUi.currentBounds;
|
|
||||||
|
|
||||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
|
||||||
// or just use a fixed box around the center.
|
|
||||||
if (!bounds) {
|
|
||||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
|
||||||
// Let's take a safe box of ~1km radius.
|
|
||||||
const delta = 0.01;
|
|
||||||
bounds = {
|
|
||||||
minLat: lat - delta,
|
|
||||||
maxLat: lat + delta,
|
|
||||||
minLon: lon - delta,
|
|
||||||
maxLon: lon + delta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pois = await this.osm.getCategoryPois(
|
|
||||||
bounds,
|
|
||||||
params.category,
|
|
||||||
lat,
|
|
||||||
lon
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by distance from center
|
|
||||||
pois = pois
|
|
||||||
.map((p) => ({
|
|
||||||
...p,
|
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
}
|
|
||||||
// Case 1: Text Search (q parameter present)
|
|
||||||
else if (params.q) {
|
|
||||||
loadingType = 'text';
|
|
||||||
loadingValue = params.q;
|
|
||||||
this.mapUi.startLoading(loadingType, loadingValue);
|
|
||||||
|
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
|
||||||
|
|
||||||
// Search local bookmarks by name
|
|
||||||
const queryLower = params.q.toLowerCase();
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
return (
|
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
|
||||||
else if (lat && lon) {
|
|
||||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
|
||||||
const searchRadius = 50; // Default radius
|
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
|
||||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
|
||||||
|
|
||||||
// Get cached/saved places in search radius
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
|
||||||
return dist <= searchRadius;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from click
|
|
||||||
pois = pois
|
|
||||||
.map((p) => {
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (loadingType && loadingValue) {
|
|
||||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
|
||||||
// We resolve them to the bookmark version if they exist
|
|
||||||
pois = pois.map((p) => {
|
|
||||||
const saved = this.storage.findPlaceById(p.osmId);
|
|
||||||
return saved || p;
|
|
||||||
});
|
|
||||||
|
|
||||||
return pois;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterModel(model, transition) {
|
|
||||||
const { q, selected } = transition.to.queryParams;
|
|
||||||
|
|
||||||
// Heuristic Match Logic (ported from MapComponent)
|
|
||||||
// If 'selected' is provided (from map click), try to find that specific feature.
|
|
||||||
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
|
||||||
const targetName = selected || q;
|
|
||||||
|
|
||||||
if (targetName && model.length > 0) {
|
|
||||||
let matchedPlace = null;
|
|
||||||
|
|
||||||
// 1. Exact Name Match
|
|
||||||
matchedPlace = model.find(
|
|
||||||
(p) =>
|
|
||||||
p.osmTags &&
|
|
||||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
|
||||||
// Note: MapComponent had logic for <=20m + type match.
|
|
||||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
|
||||||
// For now, let's stick to name or very close proximity.
|
|
||||||
if (!matchedPlace) {
|
|
||||||
const topCandidate = model[0];
|
|
||||||
if (topCandidate._distance <= 10) {
|
|
||||||
matchedPlace = topCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedPlace) {
|
|
||||||
// Direct transition!
|
|
||||||
this.router.replaceWith('place', matchedPlace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop the pulse animation since search is done (and we are staying here)
|
|
||||||
this.mapUi.stopSearch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupController(controller, model) {
|
setupController(controller, model) {
|
||||||
super.setupController(controller, model);
|
super.setupController(controller, model);
|
||||||
// Ensure pulse is stopped if we reach here
|
|
||||||
this.mapUi.stopSearch();
|
// Trigger the background task to fetch results
|
||||||
this.mapUi.setSearchResults(model);
|
controller.fetchResultsTask.perform(model);
|
||||||
|
|
||||||
// Store current search params to allow "Up" navigation from place details
|
// Store current search params to allow "Up" navigation from place details
|
||||||
const { q, category, lat, lon } = this.paramsFor('search');
|
const { q, category, lat, lon } = this.paramsFor('search');
|
||||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetController(controller, isExiting) {
|
||||||
|
if (isExiting) {
|
||||||
|
controller.fetchResultsTask.cancelAll();
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
error(error, transition) {
|
error(error, transition) {
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
|
|||||||
if (transition) {
|
if (transition) {
|
||||||
transition.abort();
|
transition.abort();
|
||||||
}
|
}
|
||||||
return false; // Prevent bubble and stop transition
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export default class MapUiService extends Service {
|
|||||||
@tracked searchResults = [];
|
@tracked searchResults = [];
|
||||||
@tracked currentSearch = null;
|
@tracked currentSearch = null;
|
||||||
@tracked loadingState = null;
|
@tracked loadingState = null;
|
||||||
|
@tracked isSidebarVisible = false;
|
||||||
|
|
||||||
|
showSidebar() {
|
||||||
|
this.isSidebarVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSidebar() {
|
||||||
|
this.isSidebarVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
|
|||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service toast;
|
||||||
rs;
|
rs;
|
||||||
widget;
|
widget;
|
||||||
@tracked placesInView = [];
|
@tracked placesInView = [];
|
||||||
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
|
|||||||
@tracked connected = false;
|
@tracked connected = false;
|
||||||
@tracked userAddress = null;
|
@tracked userAddress = null;
|
||||||
@tracked isWidgetOpen = false;
|
@tracked isWidgetOpen = false;
|
||||||
|
isNewConnection = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
|
|
||||||
|
this.checkInitialConnectionState();
|
||||||
|
|
||||||
this.rs = new RemoteStorage({
|
this.rs = new RemoteStorage({
|
||||||
modules: [Places],
|
modules: [Places],
|
||||||
});
|
});
|
||||||
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
|
||||||
|
if (this.isNewConnection) {
|
||||||
|
this.toast.show('Remote storage connected', 3000);
|
||||||
|
this.isNewConnection = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.loadLists();
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
|
|||||||
this.loadedPrefixes = [];
|
this.loadedPrefixes = [];
|
||||||
this.lists = [];
|
this.lists = [];
|
||||||
this.initialSyncDone = false;
|
this.initialSyncDone = false;
|
||||||
|
this.isNewConnection = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('sync-done', () => {
|
this.rs.on('sync-done', () => {
|
||||||
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkInitialConnectionState() {
|
||||||
|
this.isNewConnection = true;
|
||||||
|
try {
|
||||||
|
if (window.localStorage) {
|
||||||
|
const keys = [
|
||||||
|
'remotestorage:wireclient',
|
||||||
|
'remotestorage:dropbox',
|
||||||
|
'remotestorage:googledrive',
|
||||||
|
];
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = window.localStorage.getItem(key);
|
||||||
|
if (data) {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed && parsed.token) {
|
||||||
|
this.isNewConnection = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to check localStorage for existing connection:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handlePlaceChange(event) {
|
handlePlaceChange(event) {
|
||||||
const { newValue, relativePath } = event;
|
const { newValue, relativePath } = event;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1365,10 +1369,10 @@ span.icon {
|
|||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||||
|
|
||||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
/* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: 50%; /* Reset desktop shift */
|
left: 50%; /* Reset desktop shift */
|
||||||
top: 25%;
|
top: calc(25% + 30px); /* 30px approx half header height */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component {
|
|||||||
@tracked isAppMenuOpen = false;
|
@tracked isAppMenuOpen = false;
|
||||||
|
|
||||||
get isSidebarOpen() {
|
get isSidebarOpen() {
|
||||||
// We consider the sidebar "open" if we are in search or place routes.
|
// We consider the sidebar "open" if we are in search or place routes AND it's visible.
|
||||||
// This helps the map know if it should shift the center or adjust view.
|
// This helps the map know if it should shift the center or adjust view.
|
||||||
return (
|
return (
|
||||||
this.router.currentRouteName === 'place' ||
|
this.mapUi.isSidebarVisible &&
|
||||||
|
(this.router.currentRouteName === 'place' ||
|
||||||
this.router.currentRouteName === 'place.new' ||
|
this.router.currentRouteName === 'place.new' ||
|
||||||
this.router.currentRouteName === 'search'
|
this.router.currentRouteName === 'search')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component {
|
|||||||
handleOutsideClick() {
|
handleOutsideClick() {
|
||||||
if (this.isAppMenuOpen) {
|
if (this.isAppMenuOpen) {
|
||||||
this.closeAppMenu();
|
this.closeAppMenu();
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
} else if (
|
||||||
this.router.transitionTo('index');
|
this.router.currentRouteName === 'search' ||
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
this.router.currentRouteName === 'place'
|
||||||
// If in place route, decide if we want to go back to search or index
|
) {
|
||||||
// For now, let's go to index or maybe back to search if search params exist?
|
this.mapUi.clearSelection();
|
||||||
// Simplest behavior: clear selection
|
this.mapUi.hideSidebar();
|
||||||
this.router.transitionTo('index');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component {
|
|||||||
if (place === null) {
|
if (place === null) {
|
||||||
// If we have an active search context, return to it (UP navigation)
|
// If we have an active search context, return to it (UP navigation)
|
||||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('search', {
|
this.router.transitionTo('search', {
|
||||||
queryParams: this.mapUi.currentSearch,
|
queryParams: this.mapUi.currentSearch,
|
||||||
});
|
});
|
||||||
@@ -88,17 +89,19 @@ export default class PlaceTemplate extends Component {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If a place is selected (unlikely in this view, but possible if we add related links)
|
// If a place is selected (unlikely in this view, but possible if we add related links)
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
// Clear search results so we don't fall back to the list
|
this.mapUi.clearSelection();
|
||||||
this.router.transitionTo('index');
|
this.mapUi.hideSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
<PlacesSidebar
|
<PlacesSidebar
|
||||||
@selectedPlace={{this.place}}
|
@selectedPlace={{this.place}}
|
||||||
@onClose={{this.close}}
|
@onClose={{this.close}}
|
||||||
@@ -106,5 +109,6 @@ export default class PlaceTemplate extends Component {
|
|||||||
@onBookmarkChange={{this.refreshMap}}
|
@onBookmarkChange={{this.refreshMap}}
|
||||||
@onUpdate={{this.handleUpdate}}
|
@onUpdate={{this.handleUpdate}}
|
||||||
/>
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,15 +56,16 @@ export default class PlaceNewTemplate extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||||
New Place</h2>
|
New Place</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
<button
|
||||||
@name="x"
|
type="button"
|
||||||
@size={{20}}
|
class="close-btn"
|
||||||
@color="#333"
|
{{on "click" this.close}}
|
||||||
/></button>
|
><Icon @name="x" @size={{20}} @color="#333" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
@@ -79,5 +80,6 @@ export default class PlaceNewTemplate extends Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
|||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
// We don't need to manually set currentSearch here because
|
// We don't need to manually set currentSearch here because
|
||||||
// it was already set in the route's setupController
|
// it was already set in the route's setupController
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
@@ -19,14 +21,16 @@ export default class SearchTemplate extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
this.router.transitionTo('index');
|
this.mapUi.hideSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
<PlacesSidebar
|
<PlacesSidebar
|
||||||
@places={{@model}}
|
@places={{this.mapUi.searchResults}}
|
||||||
@onSelect={{this.selectPlace}}
|
@onSelect={{this.selectPlace}}
|
||||||
@onClose={{this.close}}
|
@onClose={{this.close}}
|
||||||
/>
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
|||||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||||
|
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
import loadingRing from '../icons/270-ring.svg?raw';
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
@@ -243,6 +244,7 @@ const ICONS = {
|
|||||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
whatsapp,
|
whatsapp,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
winding_way_wide: windingWayWide,
|
||||||
parking_p: parkingP,
|
parking_p: parkingP,
|
||||||
car,
|
car,
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
|
||||||
// Historic
|
// Historic
|
||||||
|
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||||
|
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"qunit": "^2.25.0",
|
"qunit": "^2.25.0",
|
||||||
"qunit-dom": "^3.5.0",
|
"qunit-dom": "^3.5.0",
|
||||||
"remotestorage-widget": "^1.8.1",
|
"remotestorage-widget": "^1.8.1",
|
||||||
"remotestoragejs": "2.0.0-beta.8",
|
"remotestoragejs": "2.0.0-beta.9",
|
||||||
"sinon": "^21.0.1",
|
"sinon": "^21.0.1",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
|
|||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -196,8 +196,8 @@ importers:
|
|||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
remotestoragejs:
|
remotestoragejs:
|
||||||
specifier: 2.0.0-beta.8
|
specifier: 2.0.0-beta.9
|
||||||
version: 2.0.0-beta.8
|
version: 2.0.0-beta.9
|
||||||
sinon:
|
sinon:
|
||||||
specifier: ^21.0.1
|
specifier: ^21.0.1
|
||||||
version: 21.0.1
|
version: 21.0.1
|
||||||
@@ -1720,9 +1720,6 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
'@types/node@20.14.0':
|
|
||||||
resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==}
|
|
||||||
|
|
||||||
'@types/node@25.0.7':
|
'@types/node@25.0.7':
|
||||||
resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==}
|
resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==}
|
||||||
|
|
||||||
@@ -1735,9 +1732,6 @@ packages:
|
|||||||
'@types/symlink-or-copy@1.2.2':
|
'@types/symlink-or-copy@1.2.2':
|
||||||
resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
|
resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
|
||||||
|
|
||||||
'@types/tv4@1.2.33':
|
|
||||||
resolution: {integrity: sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==}
|
|
||||||
|
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
@@ -4906,8 +4900,8 @@ packages:
|
|||||||
remotestorage-widget@1.8.1:
|
remotestorage-widget@1.8.1:
|
||||||
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
|
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
|
||||||
|
|
||||||
remotestoragejs@2.0.0-beta.8:
|
remotestoragejs@2.0.0-beta.9:
|
||||||
resolution: {integrity: sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==}
|
resolution: {integrity: sha512-d09ByL7ecbZLMuzl4mQ3SXMFlsCwvvINm6l1CfdR8ylvX9E1nsq44t8gmRxzW6GUS5cwonyYA4FRXYKEhARjTA==}
|
||||||
|
|
||||||
remove-types@1.0.0:
|
remove-types@1.0.0:
|
||||||
resolution: {integrity: sha512-G7Hk1Q+UJ5DvlNAoJZObxANkBZGiGdp589rVcTW/tYqJWJ5rwfraSnKSQaETN8Epaytw8J40nS/zC7bcHGv36w==}
|
resolution: {integrity: sha512-G7Hk1Q+UJ5DvlNAoJZObxANkBZGiGdp589rVcTW/tYqJWJ5rwfraSnKSQaETN8Epaytw8J40nS/zC7bcHGv36w==}
|
||||||
@@ -5578,9 +5572,6 @@ packages:
|
|||||||
underscore@1.13.7:
|
underscore@1.13.7:
|
||||||
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
||||||
|
|
||||||
undici-types@5.26.5:
|
|
||||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
|
||||||
|
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
@@ -5747,8 +5738,8 @@ packages:
|
|||||||
web-worker@1.5.0:
|
web-worker@1.5.0:
|
||||||
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
|
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
|
||||||
|
|
||||||
webfinger.js@2.8.2:
|
webfinger.js@3.0.4:
|
||||||
resolution: {integrity: sha512-Zqn9KXkGrD1tVEm029bVUIfmzef2KXs3G7OZrdqehDHtgv9YSxX1oy4RoPoMk2PHWIifwWCA0xwKZOAZqXMpfg==}
|
resolution: {integrity: sha512-5c15N1n4qCm/jGJjUt32mBdPVlSugLbAztIDNBpuDfukGz2E9NhmXPfLikayn2p3kcgEZsI/UOdOwVpxOr8qJA==}
|
||||||
|
|
||||||
webidl-conversions@7.0.0:
|
webidl-conversions@7.0.0:
|
||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
@@ -7648,10 +7639,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@20.14.0':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 5.26.5
|
|
||||||
|
|
||||||
'@types/node@25.0.7':
|
'@types/node@25.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -7665,8 +7652,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/symlink-or-copy@1.2.2': {}
|
'@types/symlink-or-copy@1.2.2': {}
|
||||||
|
|
||||||
'@types/tv4@1.2.33': {}
|
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
|
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
|
||||||
@@ -11530,13 +11515,11 @@ snapshots:
|
|||||||
|
|
||||||
remotestorage-widget@1.8.1: {}
|
remotestorage-widget@1.8.1: {}
|
||||||
|
|
||||||
remotestoragejs@2.0.0-beta.8:
|
remotestoragejs@2.0.0-beta.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.0
|
|
||||||
'@types/tv4': 1.2.33
|
|
||||||
esm: 3.2.25
|
esm: 3.2.25
|
||||||
tv4: 1.3.0
|
tv4: 1.3.0
|
||||||
webfinger.js: 2.8.2
|
webfinger.js: 3.0.4
|
||||||
xhr2: 0.2.1
|
xhr2: 0.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
@@ -12416,8 +12399,6 @@ snapshots:
|
|||||||
|
|
||||||
underscore@1.13.7: {}
|
underscore@1.13.7: {}
|
||||||
|
|
||||||
undici-types@5.26.5: {}
|
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||||
@@ -12569,7 +12550,7 @@ snapshots:
|
|||||||
|
|
||||||
web-worker@1.5.0: {}
|
web-worker@1.5.0: {}
|
||||||
|
|
||||||
webfinger.js@2.8.2: {}
|
webfinger.js@3.0.4: {}
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
|
|||||||
@@ -140,12 +140,14 @@ module('Acceptance | map search reset', function (hooks) {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for transition to index
|
// Wait for transition or UI update
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
assert.strictEqual(
|
|
||||||
currentURL(),
|
// Sidebar should be hidden, but we should stay on the search route
|
||||||
'/',
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
'Should have transitioned to index (closed sidebar)'
|
assert.ok(
|
||||||
|
currentURL().includes('category=coffee'),
|
||||||
|
'Should have stayed on the search route with markers intact'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Second Click (Start new search)
|
// Second Click (Start new search)
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
// Click the Close (X) button
|
// Click the Close (X) button
|
||||||
await click('.close-btn');
|
await click('.close-btn');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import { Promise } from 'rsvp';
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
let photonResolve;
|
||||||
|
let osmResolve;
|
||||||
|
|
||||||
class MockPhotonService extends Service {
|
class MockPhotonService extends Service {
|
||||||
cancelAll() {}
|
cancelAll() {}
|
||||||
|
|
||||||
async search(query) {
|
async search(query) {
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
if (query === 'slow') {
|
if (query === 'slow') {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
// Return a promise that we can manually resolve in the test
|
||||||
|
// to avoid race conditions with native setTimeout
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
photonResolve = () => {
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
|
|||||||
cancelAll() {}
|
cancelAll() {}
|
||||||
|
|
||||||
async getCategoryPois(bounds, category) {
|
async getCategoryPois(bounds, category) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
if (category === 'slow_category') {
|
if (category === 'slow_category') {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
return new Promise((resolve) => {
|
||||||
|
osmResolve = () => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
setupApplicationTest(hooks);
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
|
photonResolve = null;
|
||||||
|
osmResolve = null;
|
||||||
this.owner.register('service:photon', MockPhotonService);
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
this.owner.register('service:osm', MockOsmService);
|
this.owner.register('service:osm', MockOsmService);
|
||||||
});
|
});
|
||||||
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is set for text search'
|
'Loading state is set for text search'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve the manual promise so the task can finish deterministically
|
||||||
|
photonResolve();
|
||||||
|
|
||||||
await searchPromise;
|
await searchPromise;
|
||||||
|
await settled(); // Wait for ember-concurrency tasks to fully settle
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
mapUi.loadingState,
|
mapUi.loadingState,
|
||||||
null,
|
null,
|
||||||
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is set for category search'
|
'Loading state is set for category search'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve the manual promise
|
||||||
|
osmResolve();
|
||||||
|
|
||||||
await catPromise;
|
await catPromise;
|
||||||
|
await settled();
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
mapUi.loadingState,
|
mapUi.loadingState,
|
||||||
null,
|
null,
|
||||||
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
|
|
||||||
// 4. Click the clear button (should be visible since input has value)
|
// 4. Click the clear button (should be visible since input has value)
|
||||||
await click('.search-clear-btn');
|
await click('.search-clear-btn');
|
||||||
|
// Wait for the click and transition to settle
|
||||||
|
|
||||||
// Verify loading state is cleared immediately
|
// Verify loading state is cleared immediately
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@@ -130,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is cleared immediately after clicking clear'
|
'Loading state is cleared immediately after clicking clear'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up the dangling promise
|
||||||
|
if (photonResolve) {
|
||||||
|
photonResolve();
|
||||||
|
}
|
||||||
|
|
||||||
// Verify we are back on index (or at least query is gone)
|
// Verify we are back on index (or at least query is gone)
|
||||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -36,6 +36,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
selectPlaceCalled = true;
|
selectPlaceCalled = true;
|
||||||
}
|
}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -76,6 +78,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
class MapUiStub extends Service {
|
class MapUiStub extends Service {
|
||||||
selectPlace() {}
|
selectPlace() {}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -110,6 +114,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
class MapUiStub extends Service {
|
class MapUiStub extends Service {
|
||||||
selectPlace() {}
|
selectPlace() {}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -155,6 +161,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||||
}
|
}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:storage', StorageStub);
|
this.owner.register('service:storage', StorageStub);
|
||||||
|
|||||||
Reference in New Issue
Block a user