Add full-text search
Add a search box with a quick results popover, as well full results in the sidebar on pressing enter.
This commit is contained in:
@@ -5,6 +5,7 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -31,6 +32,8 @@ export default class AppHeaderComponent extends Component {
|
||||
>
|
||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
||||
</button>
|
||||
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
@@ -110,6 +110,10 @@ export default class MapComponent extends Component {
|
||||
}),
|
||||
});
|
||||
|
||||
// Initialize the UI service with the map center
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
@@ -645,12 +649,18 @@ export default class MapComponent extends Component {
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(center[1], center[0]);
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
// Calculate coordinates under the crosshair element
|
||||
// We need the pixel position of the crosshair relative to the map viewport
|
||||
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const mapRect = this.mapInstance
|
||||
.getTargetElement()
|
||||
.getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const centerX =
|
||||
@@ -786,10 +796,9 @@ export default class MapComponent extends Component {
|
||||
const queryParams = {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
if (selectedFeatureName) {
|
||||
queryParams.q = selectedFeatureName;
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
};
|
||||
|
||||
@@ -23,8 +23,10 @@ export default class PlacesSidebar extends Component {
|
||||
if (lat && lon) {
|
||||
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||
} else {
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', {
|
||||
queryParams: { lat: 0, lon: 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +154,7 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@name="x"
|
||||
@@ -184,14 +186,16 @@ export default class PlacesSidebar extends Component {
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">{{humanizeOsmTag (or
|
||||
place.osmTags.amenity
|
||||
place.osmTags.shop
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
"Point of Interest"
|
||||
)}}</div>
|
||||
<div class="place-type">{{humanizeOsmTag
|
||||
(or
|
||||
place.osmTags.amenity
|
||||
place.osmTags.shop
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
"Point of Interest"
|
||||
)
|
||||
}}</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
|
||||
178
app/components/search-box.gjs
Normal file
178
app/components/search-box.gjs
Normal file
@@ -0,0 +1,178 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { debounce } from '@ember/runloop';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class SearchBoxComponent extends Component {
|
||||
@service photon;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
|
||||
@tracked query = '';
|
||||
@tracked results = [];
|
||||
@tracked isFocused = false;
|
||||
@tracked isLoading = false;
|
||||
|
||||
get showPopover() {
|
||||
return this.isFocused && this.results.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
this.query = event.target.value;
|
||||
if (this.query.length < 2) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
debounce(this, this.performSearch, 300);
|
||||
}
|
||||
|
||||
async performSearch() {
|
||||
if (this.query.length < 2) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Use map center if available for location bias
|
||||
let lat, lon;
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
}
|
||||
const results = await this.photon.search(this.query, lat, lon);
|
||||
this.results = results;
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
this.results = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleFocus() {
|
||||
this.isFocused = true;
|
||||
if (this.query.length >= 2 && this.results.length === 0) {
|
||||
this.performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBlur() {
|
||||
// Delay hiding so clicks on results can register
|
||||
setTimeout(() => {
|
||||
this.isFocused = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!this.query) return;
|
||||
|
||||
let queryParams = { q: this.query, selected: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
@action
|
||||
selectResult(place) {
|
||||
this.query = place.title;
|
||||
this.results = []; // Hide popover
|
||||
|
||||
// If it has an OSM ID, go to place details
|
||||
if (place.osmId) {
|
||||
// Format: osm:node:123
|
||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||
this.router.transitionTo('place', id);
|
||||
} else {
|
||||
// Just a location (e.g. from Photon without OSM ID, though unlikely for Photon)
|
||||
// Or we can treat it as a search query
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: {
|
||||
q: place.title,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
selected: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.router.transitionTo('index'); // Or stay on current page?
|
||||
// Usually clear just clears the input.
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="search-box">
|
||||
<form class="search-form" {{on "submit" this.handleSubmit}}>
|
||||
<div class="search-icon">
|
||||
<Icon @name="search" @size={{18}} @color="#666" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search places..."
|
||||
aria-label="Search places"
|
||||
value={{this.query}}
|
||||
{{on "input" this.handleInput}}
|
||||
{{on "focus" this.handleFocus}}
|
||||
{{on "blur" this.handleBlur}}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{{#if this.query}}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear-btn"
|
||||
{{on "click" this.clear}}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<Icon @name="x" @size={{16}} @color="#999" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</form>
|
||||
|
||||
{{#if this.showPopover}}
|
||||
<div class="search-results-popover">
|
||||
<ul class="search-results-list">
|
||||
{{#each this.results as |result|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="search-result-item"
|
||||
{{on "click" (fn this.selectResult result)}}
|
||||
>
|
||||
<div class="result-icon">
|
||||
<Icon @name="map-pin" @size={{16}} @color="#666" />
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{{result.title}}</span>
|
||||
{{#if result.description}}
|
||||
<span class="result-desc">{{result.description}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -62,7 +62,10 @@ export default class SettingsPane extends Component {
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
@@ -73,24 +76,45 @@ export default class SettingsPane extends Component {
|
||||
<section class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p>
|
||||
<strong>Marco</strong> (as in <a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
|
||||
that respects your privacy and choices.
|
||||
<strong>Marco</strong>
|
||||
(as in
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||
privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own <a href="https://remotestorage.io/"
|
||||
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
|
||||
apps and devices.
|
||||
Connect your own
|
||||
<a
|
||||
href="https://remotestorage.io/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
<ul class="link-list">
|
||||
<li>
|
||||
<a href="https://gitea.kosmos.org/raucao/marco" target="_blank" rel="noopener">
|
||||
<a
|
||||
href="https://gitea.kosmos.org/raucao/marco"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Source Code
|
||||
</a> (<a href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License" target="_blank" rel="noopener">AGPL</a>)
|
||||
</a>
|
||||
(<a
|
||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>AGPL</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
|
||||
<a
|
||||
href="https://openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map Data © OpenStreetMap
|
||||
</a>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user