Compare commits

..

10 Commits

Author SHA1 Message Date
raucao 39dca446ac 1.25.1
CI / Lint (push) Successful in 46s
CI / Test (push) Successful in 1m10s
2026-06-30 23:12:42 +02:00
raucao 2e6827f0d0 Merge pull request 'Sort collections by createdAt' (#68) from ui/collection_sorting into master
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s
Reviewed-on: #68
2026-06-30 21:10:59 +00:00
raucao 8c3a805684 Sort collections by createdAt
CI / Lint (pull_request) Successful in 34s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 8s
2026-06-30 23:07:53 +02:00
raucao cf80c1eea7 1.25.0
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 57s
2026-06-30 19:35:52 +02:00
raucao 126806fcd4 Merge pull request 'Fix some routing and click/nav UX issues' (#67) from bugfix/search_routing into master
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s
Reviewed-on: #67
2026-06-30 17:30:26 +00:00
raucao f48045c35d Fix flaky test by adding safe guard
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-30 19:28:01 +02:00
raucao 989173a2e6 Fix bottom drawer re-animating while open
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Failing after 57s
2026-06-30 19:18:29 +02:00
raucao 62407f5fa4 Sync search form query value
* Clear input when clearing search from anywhere
* Pre-fill input when opening search URL with query params
2026-06-30 19:12:38 +02:00
raucao 7f1c4b5f61 When search markers are visible, clear search on map click 2026-06-30 18:32:23 +02:00
raucao 59e334c499 Fix place route being active after closing place details
Depending on where a place was opened from, it now goes back to the
index or (active) search route
2026-06-30 18:06:12 +02:00
19 changed files with 347 additions and 25 deletions
+52 -3
View File
@@ -9,6 +9,7 @@ import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips'; import CategoryChips from '#components/category-chips';
import { and } from 'ember-truth-helpers'; import { and } from 'ember-truth-helpers';
import cachedImage from '../modifiers/cached-image'; import cachedImage from '../modifiers/cached-image';
import { POI_CATEGORIES } from '../utils/poi-categories';
export default class AppHeaderComponent extends Component { export default class AppHeaderComponent extends Component {
@service storage; @service storage;
@@ -16,11 +17,59 @@ export default class AppHeaderComponent extends Component {
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service mapUi; @service mapUi;
@service router;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = ''; @tracked searchQuery = '';
get hasQuery() { constructor() {
return !!this.searchQuery; 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() { get showQuickSearch() {
@@ -61,7 +110,7 @@ export default class AppHeaderComponent extends Component {
</div> </div>
{{#if this.showQuickSearch}} {{#if this.showQuickSearch}}
<div class="header-center {{if this.hasQuery 'searching'}}"> <div class="header-center {{if this.isSearching 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} /> <CategoryChips @onSelect={{this.handleChipSelect}} />
</div> </div>
{{/if}} {{/if}}
+7
View File
@@ -1088,6 +1088,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon }; const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox); this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox); await this.storage.loadPlacesInBounds(bbox);
if (this.isDestroying || this.isDestroyed) return;
this.nostrData.loadPlacesInBounds(bbox); this.nostrData.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView); this.loadBookmarks(this.storage.placesInView);
@@ -1191,6 +1192,12 @@ export default class MapComponent extends Component {
return; 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 // Require Zoom >= 17 for generic map searches
// This prevents accidental searches when interacting with the map at a high level // This prevents accidental searches when interacting with the map at a high level
const currentZoom = this.mapInstance.getView().getZoom(); const currentZoom = this.mapInstance.getView().getZoom();
+11 -1
View File
@@ -4,6 +4,16 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency'; 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 { export default class ListsListController extends Controller {
@service router; @service router;
@service mapUi; @service mapUi;
@@ -73,7 +83,7 @@ export default class ListsListController extends Controller {
} }
}); });
return merged; return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
} }
@action @action
+3 -1
View File
@@ -33,7 +33,9 @@ export default class SearchController extends Controller {
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation // 2. If it's a back navigation to the exact same search, resolve instantly with no animation
if (isSameSearch && hasResults) { if (isSameSearch && hasResults) {
this.mapUi.showSidebar(); if (this.mapUi.isSidebarVisible) {
this.mapUi.showSidebar();
}
return; return;
} }
+3
View File
@@ -1424,6 +1424,9 @@ button.create-place {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
inset: auto 0 0; inset: auto 0 0;
}
.sidebar-opening .sidebar {
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1) animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
forwards; forwards;
} }
+8
View File
@@ -55,6 +55,14 @@ export default class ApplicationComponent extends Component {
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) { if (name === 'menu' || name.startsWith('lists')) {
this.router.transitionTo('index'); 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');
}
} }
} }
} }
+7
View File
@@ -104,6 +104,13 @@ export default class PlaceTemplate extends Component {
close() { close() {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,
});
} else {
this.router.transitionTo('index');
}
} }
<template> <template>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.24.0", "version": "1.25.1",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script> <script type="module" crossorigin src="/assets/main-DNM-h3Gw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css"> <link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
</head> </head>
<body> <body>
<div id="modal-portal"></div> <div id="modal-portal"></div>
+81
View File
@@ -135,4 +135,85 @@ module('Acceptance | collections navigation', function (hooks) {
'Returns gracefully back to lists/to-go list view' '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
View File
@@ -101,7 +101,7 @@ module('Acceptance | map search reset', function (hooks) {
'Should have stayed on the search route with markers intact' '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 // Click slightly differently to ensure fresh event
await triggerEvent(canvas, 'pointerdown', { await triggerEvent(canvas, 'pointerdown', {
clientX: 250, clientX: 250,
@@ -125,11 +125,6 @@ module('Acceptance | map search reset', function (hooks) {
// 3. Wait for transition // 3. Wait for transition
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
const newUrl = currentURL(); assert.strictEqual(currentURL(), '/', 'Should have transitioned to index');
assert.notOk(
newUrl.includes('category=coffee'),
`New URL ${newUrl} should not contain category param`
);
assert.ok(newUrl.includes('/search'), 'Should be on search route');
}); });
}); });
+17 -2
View File
@@ -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'); const mapUi = this.owner.lookup('service:map-ui');
await visit('/search?lat=1&lon=1'); await visit('/search?lat=1&lon=1');
@@ -97,7 +97,22 @@ module('Acceptance | navigation', function (hooks) {
await click('.close-btn'); await click('.close-btn');
assert.dom('.sidebar').doesNotExist('Sidebar should be closed'); 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) { test('navigating directly to place and back closes sidebar', async function (assert) {
+73
View File
@@ -169,4 +169,77 @@ module('Acceptance | search loading', function (hooks) {
// 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');
}); });
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');
}
});
}); });
+72
View File
@@ -270,4 +270,76 @@ module('Acceptance | search', function (hooks) {
.hasText('Search request failed. Please try again.'); .hasText('Search request failed. Please try again.');
assert.dom('.places-sidebar').doesNotExist('Results panel should not open'); 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');
});
}); });