diff --git a/app/components/map.gjs b/app/components/map.gjs
index 5b0877b..7ccaee8 100644
--- a/app/components/map.gjs
+++ b/app/components/map.gjs
@@ -782,14 +782,19 @@ export default class MapComponent extends Component {
// Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) {
// 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).
- // 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.
+ // We want the pin to be in the center of the TOP 50% (visible area), minus the header.
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;
// Shift center SOUTH (decrease Y).
@@ -849,6 +854,9 @@ export default class MapComponent extends Component {
let targetPixelY = pixel[1];
let needsPan = false;
+ const headerEl = document.querySelector('.app-header');
+ const headerHeight = headerEl ? headerEl.offsetHeight : 60;
+
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) {
const height = size[1];
@@ -856,7 +864,7 @@ export default class MapComponent extends Component {
// If in bottom half
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;
}
}
@@ -877,11 +885,10 @@ export default class MapComponent extends Component {
// 3. Header Logic (Any screen size)
// Check if the (potentially new) target Y is under the header
- const headerHeight = 60;
- const minTopDistance = headerHeight + 20; // 80px
+ const minTopDistance = headerHeight + 20; // Provide some padding
if (targetPixelY < minTopDistance) {
- targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
+ targetPixelY = minTopDistance + 30; // Move it clear of header
needsPan = true;
}
@@ -1144,6 +1151,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/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/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..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,186 +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);
+
+ // 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();
@@ -206,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/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/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;
diff --git a/app/styles/app.css b/app/styles/app.css
index 3115383..0be48a5 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -1365,10 +1365,10 @@ span.icon {
@media (width <= 768px) {
/* 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 {
left: 50%; /* Reset desktop shift */
- top: 25%;
+ top: calc(25% + 30px); /* 30px approx half header height */
}
}
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();
}
-
+ {{#if this.mapUi.isSidebarVisible}}
+
+ {{/if}}
}
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 {
}
-