Compare commits
16 Commits
78996b6c61
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
39dca446ac
|
|||
|
2e6827f0d0
|
|||
|
8c3a805684
|
|||
|
cf80c1eea7
|
|||
|
126806fcd4
|
|||
|
f48045c35d
|
|||
|
989173a2e6
|
|||
|
62407f5fa4
|
|||
|
7f1c4b5f61
|
|||
|
59e334c499
|
|||
|
981502e293
|
|||
|
9c5fbca72a
|
|||
|
fd8f04f902
|
|||
|
3e23ec161c
|
|||
|
f2e531c0f6
|
|||
|
96a5a6ac34
|
@@ -9,6 +9,7 @@ import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -16,11 +17,59 @@ export default class AppHeaderComponent extends Component {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
get hasQuery() {
|
||||
return !!this.searchQuery;
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.router && typeof this.router.on === 'function') {
|
||||
this.router.on('routeDidChange', this.syncSearchQuery);
|
||||
}
|
||||
this.syncSearchQuery();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
if (this.router && typeof this.router.off === 'function') {
|
||||
this.router.off('routeDidChange', this.syncSearchQuery);
|
||||
}
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
@action
|
||||
syncSearchQuery() {
|
||||
const qp =
|
||||
this.mapUi.currentSearch || this.router?.currentRoute?.queryParams;
|
||||
if (qp?.q) {
|
||||
this.searchQuery = qp.q;
|
||||
} else if (qp?.category) {
|
||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||
this.searchQuery = category ? category.label : qp.category;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
get isSearching() {
|
||||
// 1. If we are actively focusing/typing in the search box with a query, hide pills
|
||||
if (this.mapUi.searchBoxHasFocus && this.searchQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. If we are on the search route, check loading and results status
|
||||
if (this.router?.currentRouteName === 'search') {
|
||||
if (this.mapUi.loadingState) {
|
||||
return false; // Keep pills visible while loading
|
||||
}
|
||||
return this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||
}
|
||||
|
||||
// 3. Fallback for integration tests (non-search route with a query)
|
||||
if (this.router?.currentRouteName !== 'search' && this.searchQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
get showQuickSearch() {
|
||||
@@ -61,7 +110,7 @@ export default class AppHeaderComponent extends Component {
|
||||
</div>
|
||||
|
||||
{{#if this.showQuickSearch}}
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<div class="header-center {{if this.isSearching 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
@@ -1088,6 +1088,7 @@ export default class MapComponent extends Component {
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
this.mapUi.updateBounds(bbox);
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
if (this.isDestroying || this.isDestroyed) return;
|
||||
this.nostrData.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.placesInView);
|
||||
|
||||
@@ -1191,6 +1192,12 @@ export default class MapComponent extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||
console.debug('Clearing active search and markers on map click');
|
||||
this.router.transitionTo('index');
|
||||
return;
|
||||
}
|
||||
|
||||
// Require Zoom >= 17 for generic map searches
|
||||
// This prevents accidental searches when interacting with the map at a high level
|
||||
const currentZoom = this.mapInstance.getView().getZoom();
|
||||
|
||||
@@ -218,54 +218,58 @@ export default class PlacesSidebar extends Component {
|
||||
@onSave={{this.updateBookmark}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else if (getPlaceType place.osmTags)}}
|
||||
{{getPlaceType place.osmTags}}
|
||||
{{else}}
|
||||
Saved place
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
function getPlaceTime(place) {
|
||||
const dateVal = place.createdAt;
|
||||
if (!dateVal) return 0;
|
||||
if (typeof dateVal === 'number') {
|
||||
return dateVal;
|
||||
}
|
||||
const parsed = Date.parse(dateVal);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export default class ListsListController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
}
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||
}
|
||||
|
||||
get listColor() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
}
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
get places() {
|
||||
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||
const placeRefsIds = new Set(
|
||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||
);
|
||||
|
||||
// Filter live tracked savedPlaces that are in this list
|
||||
const livePlaces = this.storage.savedPlaces.filter((p) =>
|
||||
placeRefsIds.has(p.id)
|
||||
);
|
||||
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Process live state first to reflect deletions/edits immediately
|
||||
livePlaces.forEach((p) => {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
});
|
||||
|
||||
// Supplement with any background-fetched places that are still valid but not in live state yet
|
||||
this.loadedPlaces.forEach((p) => {
|
||||
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition(
|
||||
`list-${this.listId}`,
|
||||
sidebarContent.scrollTop
|
||||
);
|
||||
}
|
||||
this.mapUi.returnToRoute = {
|
||||
name: 'lists.list',
|
||||
model: this.listId,
|
||||
};
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToLists() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,26 @@ export default class SearchController extends Controller {
|
||||
category = null;
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// Hide sidebar and clear previous results immediately to signal a new search
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
this.mapUi.currentSearch &&
|
||||
params.q === this.mapUi.currentSearch.q &&
|
||||
params.category === this.mapUi.currentSearch.category &&
|
||||
params.lat === this.mapUi.currentSearch.lat &&
|
||||
params.lon === this.mapUi.currentSearch.lon;
|
||||
|
||||
const hasResults =
|
||||
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||
|
||||
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
||||
if (isSameSearch && hasResults) {
|
||||
if (this.mapUi.isSidebarVisible) {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
|
||||
this.mapUi.hideSidebar();
|
||||
this.mapUi.clearSearchResults();
|
||||
|
||||
|
||||
@@ -4,14 +4,23 @@ import { service } from '@ember/service';
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
async model(params) {
|
||||
const listId = params.list_id;
|
||||
try {
|
||||
const places = await this.storage.getPlacesInList(listId);
|
||||
return { listId, places };
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
return { listId, places: [] };
|
||||
model(params) {
|
||||
// Resolve instantly so transition happens in 0ms!
|
||||
return { list_id: params.list_id };
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
console.debug('DEBUG: setupController controller is:', controller);
|
||||
console.debug(
|
||||
'DEBUG: controller.loadPlacesTask is:',
|
||||
controller?.loadPlacesTask
|
||||
);
|
||||
controller.model = model;
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadPlacesTask) {
|
||||
controller.loadPlacesTask.perform(model.list_id);
|
||||
} else {
|
||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,6 +1424,9 @@ button.create-place {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
inset: auto 0 0;
|
||||
}
|
||||
|
||||
.sidebar-opening .sidebar {
|
||||
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
||||
forwards;
|
||||
}
|
||||
@@ -2250,3 +2253,11 @@ button.create-place {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Sidebar Loading State */
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ export default class ApplicationComponent extends Component {
|
||||
this.mapUi.hideSidebar();
|
||||
if (name === 'menu' || name.startsWith('lists')) {
|
||||
this.router.transitionTo('index');
|
||||
} else if (name === 'place') {
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-107
@@ -1,109 +1,16 @@
|
||||
import Component from '@glimmer/component';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class ListsListTemplate extends Component {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
|
||||
get listId() {
|
||||
return this.args.model?.listId;
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||
}
|
||||
|
||||
get listColor() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
}
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
get places() {
|
||||
const modelPlaces = this.args.model?.places || [];
|
||||
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||
const placeRefsIds = new Set(
|
||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||
);
|
||||
|
||||
// Filter live tracked savedPlaces that are in this list
|
||||
const livePlaces = this.storage.savedPlaces.filter((p) =>
|
||||
placeRefsIds.has(p.id)
|
||||
);
|
||||
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Process live state first to reflect deletions/edits immediately
|
||||
livePlaces.forEach((p) => {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
});
|
||||
|
||||
// Supplement with any model-fetched places that are still valid but not in live state yet
|
||||
modelPlaces.forEach((p) => {
|
||||
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition(
|
||||
`list-${this.listId}`,
|
||||
sidebarContent.scrollTop
|
||||
);
|
||||
}
|
||||
this.mapUi.returnToRoute = {
|
||||
name: 'lists.list',
|
||||
model: this.listId,
|
||||
};
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToLists() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.places}}
|
||||
@title={{this.listTitle}}
|
||||
@color={{this.listColor}}
|
||||
@scrollTop={{this.scrollTop}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
@onBack={{this.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{@controller.places}}
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@@ -104,6 +104,13 @@ export default class PlaceTemplate extends Component {
|
||||
close() {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -52,7 +52,7 @@
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "~1.2.1",
|
||||
"@remotestorage/module-places": "~1.3.0",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
|
||||
Generated
+5
-5
@@ -88,8 +88,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@remotestorage/module-places':
|
||||
specifier: ~1.2.1
|
||||
version: 1.2.1
|
||||
specifier: ~1.3.0
|
||||
version: 1.3.0
|
||||
'@rollup/plugin-babel':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||
@@ -1468,8 +1468,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
|
||||
'@remotestorage/module-places@1.3.0':
|
||||
resolution: {integrity: sha512-VM0CqkIP6IBEpjqJ2DyTrGDOQXc73aXoAFDLIoME5Lo033uTitgn+qKgTGPK/lD4H92mk1+D3W88ECtf9v3mWw==}
|
||||
|
||||
'@rollup/plugin-babel@6.1.0':
|
||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||
@@ -7439,7 +7439,7 @@ snapshots:
|
||||
'@pnpm/error': 1000.0.5
|
||||
find-up: 5.0.0
|
||||
|
||||
'@remotestorage/module-places@1.2.1':
|
||||
'@remotestorage/module-places@1.3.0':
|
||||
dependencies:
|
||||
latlon-geohash: 2.0.0
|
||||
ulid: 3.0.2
|
||||
|
||||
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
+2
-2
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||
<script type="module" crossorigin src="/assets/main-DNM-h3Gw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async fetchOsmObject() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-123',
|
||||
title: 'Mountain Trail',
|
||||
geohash: 'u33dc0',
|
||||
osmTags: { name: 'Mountain Trail' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
|
||||
},
|
||||
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
if (id === 'place-123') {
|
||||
return this.savedPlaces[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve([this.savedPlaces[0]]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
module('Acceptance | collections navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
});
|
||||
|
||||
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
|
||||
// 1. Visit Home Map
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// 2. Open the App Menu overlay
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
assert
|
||||
.dom('.app-menu')
|
||||
.includesText('Collections', 'Menu contains Collections link');
|
||||
|
||||
// 3. Transition to Collections Index (List of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('Collections', 'Header is centered and titled Collections');
|
||||
assert
|
||||
.dom('.lists-index-item')
|
||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
||||
|
||||
// 4. Transition to a specific list (Want to go)
|
||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Transitions instantly to /lists/to-go'
|
||||
);
|
||||
|
||||
// 5. Verify background loading spinner shows up, then results populate
|
||||
await waitFor('.places-list');
|
||||
assert
|
||||
.dom('.places-list .place-name')
|
||||
.hasText('Mountain Trail', 'Renders the saved place from the list');
|
||||
assert
|
||||
.dom('.places-list .place-type')
|
||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||
|
||||
// 6. Click back button in collection list header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||
|
||||
// 7. Click back button in collections index header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||
|
||||
// 8. Close sidebar
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
||||
});
|
||||
|
||||
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Click on the place item to view details
|
||||
await click('.place-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/place-123'),
|
||||
'Transitions to place details route'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Returns gracefully back to lists/to-go list view'
|
||||
);
|
||||
});
|
||||
|
||||
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
||||
class SortedMockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-oldest',
|
||||
title: 'Oldest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-01T12:00:00.000Z',
|
||||
osmTags: { name: 'Oldest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-newest',
|
||||
title: 'Newest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-03T12:00:00.000Z',
|
||||
osmTags: { name: 'Newest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-middle',
|
||||
title: 'Middle Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-02T12:00:00.000Z',
|
||||
updatedAt: '2023-01-04T12:00:00.000Z',
|
||||
osmTags: { name: 'Middle Place' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [
|
||||
{ id: 'place-oldest', geohash: 'u33dc0' },
|
||||
{ id: 'place-newest', geohash: 'u33dc0' },
|
||||
{ id: 'place-middle', geohash: 'u33dc0' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve(this.savedPlaces);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', SortedMockStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const placeNames = Array.from(
|
||||
document.querySelectorAll('.places-list .place-name')
|
||||
).map((el) => el.textContent.trim());
|
||||
|
||||
assert.deepEqual(
|
||||
placeNames,
|
||||
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||
'Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { module, test } from 'qunit';
|
||||
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Acceptance | map search reset', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
@@ -17,58 +16,10 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
// Stub window.fetch using Sinon
|
||||
// We want to intercept map style requests and let everything else through
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Liberty',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#123456',
|
||||
},
|
||||
},
|
||||
],
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to the original implementation
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
// Restore the original fetch
|
||||
this.fetchStub.restore();
|
||||
});
|
||||
|
||||
test('clicking the map clears the category search parameter', async function (assert) {
|
||||
@@ -150,7 +101,7 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
'Should have stayed on the search route with markers intact'
|
||||
);
|
||||
|
||||
// Second Click (Start new search)
|
||||
// Second Click (Clear search and markers)
|
||||
// Click slightly differently to ensure fresh event
|
||||
await triggerEvent(canvas, 'pointerdown', {
|
||||
clientX: 250,
|
||||
@@ -174,11 +125,6 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
// 3. Wait for transition
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const newUrl = currentURL();
|
||||
assert.notOk(
|
||||
newUrl.includes('category=coffee'),
|
||||
`New URL ${newUrl} should not contain category param`
|
||||
);
|
||||
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
||||
assert.strictEqual(currentURL(), '/', 'Should have transitioned to index');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ module('Acceptance | navigation', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||
test('closing the sidebar transitions back to search route when opened from search results', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
@@ -97,7 +97,22 @@ module('Acceptance | navigation', function (hooks) {
|
||||
await click('.close-btn');
|
||||
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/search?lat=1&lon=1',
|
||||
'Should transition back to search route'
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar when visiting a place directly transitions to index', async function (assert) {
|
||||
await visit('/place/osm:node:123');
|
||||
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
||||
|
||||
// Click the Close (X) button
|
||||
await click('.close-btn');
|
||||
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.strictEqual(currentURL(), '/', 'Should transition back to index');
|
||||
});
|
||||
|
||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||
|
||||
@@ -169,4 +169,77 @@ module('Acceptance | search loading', function (hooks) {
|
||||
// Verify we are back on index (or at least query is gone)
|
||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||
});
|
||||
|
||||
test('quick search pills visibility during category search transition', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
mapUi.currentZoom = 15;
|
||||
|
||||
// Seed localStorage with a high zoom level to ensure quick search buttons show
|
||||
const highZoomState = {
|
||||
center: [13.4, 52.5],
|
||||
zoom: 18,
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
try {
|
||||
// Make sure quick search buttons setting is enabled
|
||||
const settings = this.owner.lookup('service:settings');
|
||||
settings.showQuickSearchButtons = true;
|
||||
|
||||
// 1. Visit slowly loading category search
|
||||
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Verify loading state is set and pills are visible (i.e. header-center does NOT have .searching)
|
||||
assert.ok(mapUi.loadingState, 'Search is loading');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.doesNotHaveClass(
|
||||
'searching',
|
||||
'Pills remain visible while search is loading'
|
||||
);
|
||||
|
||||
// Resolve the promise with empty results
|
||||
osmResolve();
|
||||
await catPromise;
|
||||
await settled();
|
||||
|
||||
// Verify search completed and since results are empty, pills are still visible
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'No results found');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.doesNotHaveClass(
|
||||
'searching',
|
||||
'Pills remain visible after search completes with no results'
|
||||
);
|
||||
|
||||
// 2. Now simulate a fast category search that returns results
|
||||
const osmService = this.owner.lookup('service:osm');
|
||||
osmService.getCategoryPois = async () => [
|
||||
{
|
||||
title: 'Latte Art Cafe',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '101',
|
||||
osmType: 'N',
|
||||
},
|
||||
];
|
||||
|
||||
await visit('/search?category=coffee&lat=1&lon=1');
|
||||
|
||||
// Verify search completed with results, so pills are hidden
|
||||
assert.ok(mapUi.searchResults.length > 0, 'Results found');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.hasClass(
|
||||
'searching',
|
||||
'Pills are hidden after search completes with results'
|
||||
);
|
||||
} finally {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,4 +270,76 @@ module('Acceptance | search', function (hooks) {
|
||||
.hasText('Search request failed. Please try again.');
|
||||
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
|
||||
});
|
||||
|
||||
test('search box query synchronized with active route query parameters', async function (assert) {
|
||||
// Mock Osm Service
|
||||
class MockOsmService extends Service {
|
||||
async getCategoryPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Storage Service
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Map Service
|
||||
class MockMapService extends Service {
|
||||
getBounds() {
|
||||
return {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.6,
|
||||
maxLon: 13.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
// 1. Visit a search URL directly
|
||||
await visit('/search?q=Berlin');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue(
|
||||
'Berlin',
|
||||
'Search input is populated with search term on direct load'
|
||||
);
|
||||
|
||||
// 2. Visit a category search URL
|
||||
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue(
|
||||
'Coffee',
|
||||
'Search input is populated with mapped category label'
|
||||
);
|
||||
|
||||
// 3. Go back to index
|
||||
await visit('/');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('', 'Search input is cleared on transitioning to index');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,92 @@ import {
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
|
||||
function setupMapStyleMocks(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
// Stub window.fetch to capture map-style assets before they hit the network
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (typeof url === 'string' && url.includes('tiles.openfreemap.org')) {
|
||||
// A. Mock Style Sheet
|
||||
if (url.includes('/styles/liberty')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Mock Style',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
tiles: [], // Empty tiles list prevents any map tile fetching completely!
|
||||
},
|
||||
},
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f9fa' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// B. Mock Sprite Atlas JSON
|
||||
if (url.endsWith('.json') && url.includes('/sprites/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}), // Empty sprite dictionary
|
||||
};
|
||||
}
|
||||
|
||||
// C. Mock Sprite Atlas PNG (Returns a valid 1x1 transparent PNG)
|
||||
if (url.endsWith('.png') && url.includes('/sprites/')) {
|
||||
const bytes = new Uint8Array([
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73,
|
||||
68, 65, 84, 120, 156, 99, 96, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0,
|
||||
0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
]);
|
||||
const blob = new Blob([bytes], { type: 'image/png' });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}
|
||||
|
||||
// Catch-all mock for other openfreemap endpoints
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to original fetch (e.g. Photon results, local mock APIs)
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
if (this.fetchStub && typeof this.fetchStub.restore === 'function') {
|
||||
this.fetchStub.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This file exists to provide wrappers around ember-qunit's
|
||||
// test setup functions. This way, you can easily extend the setup that is
|
||||
@@ -12,6 +98,7 @@ import { setupNostrMocks } from './mock-nostr';
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user