From a0b4a4b3f3480eb457ef2187777317d5043b6124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 12:47:33 +0100 Subject: [PATCH 1/6] Show toast notification when adding RS account --- app/services/storage.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/services/storage.js b/app/services/storage.js index b360d92..81732ae 100644 --- a/app/services/storage.js +++ b/app/services/storage.js @@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm'; export default class StorageService extends Service { @service osm; + @service toast; rs; widget; @tracked placesInView = []; @@ -23,10 +24,13 @@ export default class StorageService extends Service { @tracked connected = false; @tracked userAddress = null; @tracked isWidgetOpen = false; + isNewConnection = true; constructor() { super(...arguments); + this.checkInitialConnectionState(); + this.rs = new RemoteStorage({ modules: [Places], }); @@ -57,6 +61,12 @@ export default class StorageService extends Service { this.rs.on('connected', () => { this.connected = true; this.userAddress = this.rs.remote.userAddress; + + if (this.isNewConnection) { + this.toast.show('Remote storage connected', 3000); + this.isNewConnection = false; + } + this.loadLists(); }); @@ -72,6 +82,7 @@ export default class StorageService extends Service { this.loadedPrefixes = []; this.lists = []; this.initialSyncDone = false; + this.isNewConnection = true; }); 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) { const { newValue, relativePath } = event; From d2eb888dcfd25ec8689ed12f7cd1429ada02e8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 14:27:25 +0100 Subject: [PATCH 2/6] Keep search results, only hide sidebar when closed --- app/components/map.gjs | 2 ++ app/routes/place.js | 1 + app/routes/place/new.js | 1 + app/routes/search.js | 1 + app/services/map-ui.js | 9 ++++++++ app/templates/application.gjs | 22 +++++++++--------- app/templates/place.gjs | 22 ++++++++++-------- app/templates/place/new.gjs | 42 ++++++++++++++++++----------------- app/templates/search.gjs | 15 ++++++++----- 9 files changed, 69 insertions(+), 46 deletions(-) diff --git a/app/components/map.gjs b/app/components/map.gjs index 5b0877b..fcf9995 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -1144,6 +1144,8 @@ export default class MapComponent extends Component { this.mapUi.returnToSearch = true; } this.mapUi.preventNextZoom = true; + this.mapUi.selectPlace(place, { preventZoom: true }); + this.mapUi.showSidebar(); this.router.transitionTo('place', place); }; diff --git a/app/routes/place.js b/app/routes/place.js index 5742974..d58563b 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -96,6 +96,7 @@ export default class PlaceRoute extends Route { if (model) { const options = { preventZoom: this.mapUi.preventNextZoom }; this.mapUi.selectPlace(model, options); + this.mapUi.showSidebar(); this.mapUi.preventNextZoom = false; } // Stop the pulse animation if it was running (e.g. redirected from search) diff --git a/app/routes/place/new.js b/app/routes/place/new.js index 33cfb6a..69727e6 100644 --- a/app/routes/place/new.js +++ b/app/routes/place/new.js @@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route { this.mapUi.updateCreationCoordinates(model.lat, model.lon); } this.mapUi.startCreating(); + this.mapUi.showSidebar(); } deactivate() { diff --git a/app/routes/search.js b/app/routes/search.js index e47eb08..1d9269c 100644 --- a/app/routes/search.js +++ b/app/routes/search.js @@ -193,6 +193,7 @@ export default class SearchRoute extends Route { // Ensure pulse is stopped if we reach here this.mapUi.stopSearch(); this.mapUi.setSearchResults(model); + this.mapUi.showSidebar(); // Store current search params to allow "Up" navigation from place details const { q, category, lat, lon } = this.paramsFor('search'); diff --git a/app/services/map-ui.js b/app/services/map-ui.js index 63e9603..b5a7823 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -17,6 +17,15 @@ export default class MapUiService extends Service { @tracked searchResults = []; @tracked currentSearch = null; @tracked loadingState = null; + @tracked isSidebarVisible = false; + + showSidebar() { + this.isSidebarVisible = true; + } + + hideSidebar() { + this.isSidebarVisible = false; + } selectPlace(place, options = {}) { this.selectedPlace = place; diff --git a/app/templates/application.gjs b/app/templates/application.gjs index 8dfd51b..091ae80 100644 --- a/app/templates/application.gjs +++ b/app/templates/application.gjs @@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component { @tracked isAppMenuOpen = false; 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. return ( - this.router.currentRouteName === 'place' || - this.router.currentRouteName === 'place.new' || - this.router.currentRouteName === 'search' + this.mapUi.isSidebarVisible && + (this.router.currentRouteName === 'place' || + this.router.currentRouteName === 'place.new' || + this.router.currentRouteName === 'search') ); } @@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component { handleOutsideClick() { if (this.isAppMenuOpen) { this.closeAppMenu(); - } else if (this.router.currentRouteName === 'search') { - this.router.transitionTo('index'); - } else if (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? - // Simplest behavior: clear selection - this.router.transitionTo('index'); + } else if ( + this.router.currentRouteName === 'search' || + this.router.currentRouteName === 'place' + ) { + this.mapUi.clearSelection(); + this.mapUi.hideSidebar(); } } diff --git a/app/templates/place.gjs b/app/templates/place.gjs index b39c515..e2f20c6 100644 --- a/app/templates/place.gjs +++ b/app/templates/place.gjs @@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component { if (place === null) { // If we have an active search context, return to it (UP navigation) if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { + this.mapUi.showSidebar(); this.router.transitionTo('search', { queryParams: this.mapUi.currentSearch, }); @@ -88,23 +89,26 @@ export default class PlaceTemplate extends Component { } } else { // If a place is selected (unlikely in this view, but possible if we add related links) + this.mapUi.showSidebar(); this.router.transitionTo('place', place); } } @action close() { - // Clear search results so we don't fall back to the list - this.router.transitionTo('index'); + this.mapUi.clearSelection(); + this.mapUi.hideSidebar(); } } diff --git a/app/templates/place/new.gjs b/app/templates/place/new.gjs index 6dcbf8f..1993924 100644 --- a/app/templates/place/new.gjs +++ b/app/templates/place/new.gjs @@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component { } } diff --git a/app/templates/search.gjs b/app/templates/search.gjs index 854f60a..26b52da 100644 --- a/app/templates/search.gjs +++ b/app/templates/search.gjs @@ -11,6 +11,7 @@ export default class SearchTemplate extends Component { selectPlace(place) { if (place) { this.mapUi.returnToSearch = true; + this.mapUi.showSidebar(); // We don't need to manually set currentSearch here because // it was already set in the route's setupController this.router.transitionTo('place', place); @@ -19,14 +20,16 @@ export default class SearchTemplate extends Component { @action close() { - this.router.transitionTo('index'); + this.mapUi.hideSidebar(); } } From cf251f702b1d343f988f5d9e6b3de50f80008d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 14:44:35 +0100 Subject: [PATCH 3/6] Prevent zoom when opening place from search results --- app/templates/search.gjs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/templates/search.gjs b/app/templates/search.gjs index 26b52da..058067d 100644 --- a/app/templates/search.gjs +++ b/app/templates/search.gjs @@ -12,6 +12,7 @@ export default class SearchTemplate extends Component { if (place) { this.mapUi.returnToSearch = true; this.mapUi.showSidebar(); + this.mapUi.preventNextZoom = true; // We don't need to manually set currentSearch here because // it was already set in the route's setupController this.router.transitionTo('place', place); From cff19980d53c12489b4f8ef53a6e85d4e76f71ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 27 Apr 2026 15:04:17 +0100 Subject: [PATCH 4/6] Refactor search route/loading * Fetch results asynchronously after app launch * Hide sidebar and search results when new search is issued --- app/controllers/search.js | 181 ++++++++++++++++++++ app/routes/search.js | 191 ++-------------------- app/templates/search.gjs | 2 +- app/utils/icons.js | 2 + app/utils/osm-icons.js | 2 + tests/acceptance/map-search-reset-test.js | 12 +- tests/acceptance/navigation-test.js | 4 +- tests/acceptance/search-loading-test.js | 2 + tests/unit/routes/place-test.js | 8 + 9 files changed, 219 insertions(+), 185 deletions(-) diff --git a/app/controllers/search.js b/app/controllers/search.js index b5849aa..f96a803 100644 --- a/app/controllers/search.js +++ b/app/controllers/search.js @@ -1,6 +1,16 @@ 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 { + @service osm; + @service photon; + @service mapUi; + @service storage; + @service router; + @service toast; + queryParams = ['lat', 'lon', 'q', 'selected', 'category']; lat = null; @@ -8,4 +18,175 @@ export default class SearchController extends Controller { q = null; selected = 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(); + }); } diff --git a/app/routes/search.js b/app/routes/search.js index 1d9269c..bbf6e69 100644 --- a/app/routes/search.js +++ b/app/routes/search.js @@ -1,14 +1,9 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; import { action } from '@ember/object'; -import { getDistance } from '../utils/geo'; export default class SearchRoute extends Route { - @service osm; - @service photon; @service mapUi; - @service storage; - @service router; @service toast; queryParams = { @@ -19,187 +14,29 @@ export default class SearchRoute extends Route { category: { refreshModel: true }, }; - async model(params) { - 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); - } - } 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(); + model(params) { + // Just return params, doing the async fetch in the controller + return params; } setupController(controller, model) { super.setupController(controller, model); - // Ensure pulse is stopped if we reach here - this.mapUi.stopSearch(); - this.mapUi.setSearchResults(model); - this.mapUi.showSidebar(); + + // Trigger the background task to fetch results + controller.fetchResultsTask.perform(model); // Store current search params to allow "Up" navigation from place details const { q, category, lat, lon } = this.paramsFor('search'); this.mapUi.currentSearch = { q, category, lat, lon }; } + resetController(controller, isExiting) { + if (isExiting) { + controller.fetchResultsTask.cancelAll(); + this.mapUi.stopSearch(); + } + } + @action error(error, transition) { this.mapUi.stopSearch(); @@ -207,6 +44,6 @@ export default class SearchRoute extends Route { if (transition) { transition.abort(); } - return false; // Prevent bubble and stop transition + return false; } } diff --git a/app/templates/search.gjs b/app/templates/search.gjs index 058067d..49f83e3 100644 --- a/app/templates/search.gjs +++ b/app/templates/search.gjs @@ -27,7 +27,7 @@ export default class SearchTemplate extends Component {