Compare commits

...

24 Commits

Author SHA1 Message Date
96a5a6ac34 Show "Saved place" type for non-OSM places in lists 2026-06-30 15:11:53 +02:00
78996b6c61 Refactor sidebar header styles, center icon titles 2026-06-30 14:51:01 +02:00
bb5b69711c Use list colors for list header icons 2026-06-30 14:05:25 +02:00
ad9c489102 Refactor app menu, add place lists
Unify sidebar, make everything route-based
2026-06-30 13:28:48 +02:00
c11882adfb Merge pull request 'Animate sidebar/bottom drawer sliding into view' (#64) from ui/animations into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 57s
Reviewed-on: #64
2026-06-29 16:37:16 +00:00
707f4ac11c Animate sidebar/bottom drawer sliding into view
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-06-29 18:21:17 +02:00
3bada05b63 Merge pull request 'Various UI improvements' (#63) from ui/various into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 55s
Reviewed-on: #63
2026-06-29 15:37:25 +00:00
f01730fef5 Add icon and quick search results for tourist information
All checks were successful
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-29 17:32:26 +02:00
448c51bab6 Add icons for city gates and historic towers 2026-06-29 17:28:57 +02:00
0bcbae374b Use "yes" tag values only as fallbacks if there isn't a more specific
OSM key
2026-06-29 17:28:00 +02:00
c33fe3b268 Add icon for car repair shops 2026-06-29 17:08:55 +02:00
18bda60310 Add guest houses to hotel quick search 2026-06-29 17:02:43 +02:00
86d25dc6ba Add OSM links for custom saved places
Link to the OSM search route, so we get a pin when opening OSM from
Marco
2026-06-29 16:45:49 +02:00
5b8bec6a00 Cut off overlong sidebar link texts with ellipses 2026-06-29 16:06:55 +02:00
f2c2eb1fdc Add icon for amenity=townhall 2026-06-29 15:55:28 +02:00
b42c4881f6 Merge pull request 'Add missing icons, test for missing icons in rules' (#62) from bugfix/icons into master
All checks were successful
CI / Lint (push) Successful in 34s
CI / Test (push) Successful in 58s
Reviewed-on: #62
2026-06-29 13:38:34 +00:00
b18e299eca Add missing icons, test for missing icons in rules
All checks were successful
CI / Lint (pull_request) Successful in 34s
CI / Test (pull_request) Successful in 59s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-06-29 15:32:48 +02:00
401ed41fcd Merge pull request 'Turn default relays into required relays' (#61) from nostr/required_relays into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
Reviewed-on: #61
2026-06-07 12:30:38 +00:00
504e8fab94 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-07 16:28:09 +04:00
76897c9e69 Turn default relays into required relays
Some checks failed
CI / Lint (pull_request) Failing after 31s
CI / Test (pull_request) Successful in 55s
2026-06-07 16:21:26 +04:00
59bc5ca046 1.24.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 55s
2026-06-06 12:12:06 +04:00
ef4bb8f51a Merge pull request 'Include saved places in search results' (#59) from feature/search_saved_places into master
All checks were successful
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s
Reviewed-on: #59
2026-06-06 08:03:32 +00:00
f82a797720 Include list names in search results for saved places
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 16s
2026-06-06 12:00:48 +04:00
f9cb22ee0e Include saved places in search results 2026-06-06 11:47:27 +04:00
47 changed files with 1578 additions and 196 deletions

View File

@@ -3,11 +3,11 @@ import Icon from '#components/icon';
<template>
{{! template-lint-disable no-nested-interactive }}
<div class="sidebar-header">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>About</h2>
<h2 class="sidebar-header-text-centered">About</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>

View File

@@ -19,6 +19,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<div class="sidebar-content">
<ul class="app-menu">
<li>
<button type="button" {{on "click" @onSavedPlaces}}>
<Icon @name="bookmark" @size={{20}} />
<span>Collections</span>
</button>
</li>
<li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} />

View File

@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper';
import { service } from '@ember/service';
import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuHome from './home';
@@ -9,6 +10,7 @@ import AppMenuSettings from './settings';
import AppMenuAbout from './about';
export default class AppMenu extends Component {
@service router;
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
@action
@@ -16,10 +18,19 @@ export default class AppMenu extends Component {
this.currentView = view;
}
@action
goToSavedPlaces() {
this.router.transitionTo('lists.index');
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
<AppMenuHome
@onNavigate={{this.setView}}
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
/>
{{else if (eq this.currentView "settings")}}
<AppMenuSettings

View File

@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
}
<template>
<div class="sidebar-header">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>Settings</h2>
<h2 class="sidebar-header-text-centered">Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>

View File

@@ -5,7 +5,11 @@ import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { normalizeRelayUrl } from '../../../utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
} from '../../../utils/nostr';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
@@ -17,6 +21,74 @@ export default class AppMenuSettingsNostr extends Component {
@tracked newReadRelay = '';
@tracked newWriteRelay = '';
get customReadRelays() {
return excludeRequiredRelays(
this.settings.nostrReadRelays || [],
this.nostrData.requiredReadRelays
);
}
get customWriteRelays() {
return excludeRequiredRelays(
this.settings.nostrWriteRelays || [],
this.nostrData.requiredWriteRelays
);
}
get readRelayExclusions() {
return this.settings.nostrReadRelayExclusions || [];
}
get writeRelayExclusions() {
return this.settings.nostrWriteRelayExclusions || [];
}
get requiredReadRelaySet() {
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
}
get requiredWriteRelaySet() {
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
}
get mailboxReadRelaySet() {
return new Set(this.nostrData.mailboxReadRelays);
}
get mailboxWriteRelaySet() {
return new Set(this.nostrData.mailboxWriteRelays);
}
get hasReadOverrides() {
return (
this.customReadRelays.length > 0 || this.readRelayExclusions.length > 0
);
}
get hasWriteOverrides() {
return (
this.customWriteRelays.length > 0 || this.writeRelayExclusions.length > 0
);
}
get readRelaysForDisplay() {
return this.nostrData.activeReadRelays.map((url) => {
return {
url,
isRequired: this.requiredReadRelaySet.has(url),
};
});
}
get writeRelaysForDisplay() {
return this.nostrData.activeWriteRelays.map((url) => {
return {
url,
isRequired: this.requiredWriteRelaySet.has(url),
};
});
}
@action
updateNewReadRelay(event) {
this.newReadRelay = event.target.value;
@@ -32,19 +104,51 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newReadRelay);
if (!url) return;
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const set = new Set([...current, url]);
this.settings.update('nostrReadRelays', Array.from(set));
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [
...this.customReadRelays,
url,
]);
const custom = excludeRequiredRelays(
merged,
this.nostrData.requiredReadRelays
);
const readExclusions = this.readRelayExclusions.filter((relay) => {
return normalizeRelayUrl(relay) !== url;
});
this.settings.update('nostrReadRelays', custom.length > 0 ? custom : null);
this.settings.update(
'nostrReadRelayExclusions',
readExclusions.length > 0 ? readExclusions : null
);
this.newReadRelay = '';
}
@action
removeReadRelay(url) {
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrReadRelays', filtered);
if (this.requiredReadRelaySet.has(url)) {
return;
}
const normalizedUrl = normalizeRelayUrl(url);
const remainingCustom = this.customReadRelays.filter((relay) => {
return normalizeRelayUrl(relay) !== normalizedUrl;
});
const nextExclusions = this.mailboxReadRelaySet.has(normalizedUrl)
? Array.from(new Set([...this.readRelayExclusions, normalizedUrl]))
: this.readRelayExclusions;
this.settings.update(
'nostrReadRelays',
remainingCustom.length > 0 ? remainingCustom : null
);
this.settings.update(
'nostrReadRelayExclusions',
nextExclusions.length > 0 ? nextExclusions : null
);
}
@action
@@ -64,6 +168,7 @@ export default class AppMenuSettingsNostr extends Component {
@action
resetReadRelays() {
this.settings.update('nostrReadRelays', null);
this.settings.update('nostrReadRelayExclusions', null);
}
@action
@@ -71,24 +176,57 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newWriteRelay);
if (!url) return;
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const set = new Set([...current, url]);
this.settings.update('nostrWriteRelays', Array.from(set));
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [
...this.customWriteRelays,
url,
]);
const custom = excludeRequiredRelays(
merged,
this.nostrData.requiredWriteRelays
);
const writeExclusions = this.writeRelayExclusions.filter((relay) => {
return normalizeRelayUrl(relay) !== url;
});
this.settings.update('nostrWriteRelays', custom.length > 0 ? custom : null);
this.settings.update(
'nostrWriteRelayExclusions',
writeExclusions.length > 0 ? writeExclusions : null
);
this.newWriteRelay = '';
}
@action
removeWriteRelay(url) {
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrWriteRelays', filtered);
if (this.requiredWriteRelaySet.has(url)) {
return;
}
const normalizedUrl = normalizeRelayUrl(url);
const remainingCustom = this.customWriteRelays.filter((relay) => {
return normalizeRelayUrl(relay) !== normalizedUrl;
});
const nextExclusions = this.mailboxWriteRelaySet.has(normalizedUrl)
? Array.from(new Set([...this.writeRelayExclusions, normalizedUrl]))
: this.writeRelayExclusions;
this.settings.update(
'nostrWriteRelays',
remainingCustom.length > 0 ? remainingCustom : null
);
this.settings.update(
'nostrWriteRelayExclusions',
nextExclusions.length > 0 ? nextExclusions : null
);
}
@action
resetWriteRelays() {
this.settings.update('nostrWriteRelays', null);
this.settings.update('nostrWriteRelayExclusions', null);
}
@action
@@ -112,18 +250,20 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeReadRelays as |relay|}}
{{#each this.readRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</li>
{{/each}}
</ul>
@@ -143,7 +283,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addReadRelay}}
>Add</button>
</div>
{{#if this.settings.nostrReadRelays}}
{{#if this.hasReadOverrides}}
<button
type="button"
class="btn-link reset-relays"
@@ -157,18 +297,20 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group">
<label for="new-write-relay">Write Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeWriteRelays as |relay|}}
{{#each this.writeRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</li>
{{/each}}
</ul>
@@ -188,7 +330,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addWriteRelay}}
>Add</button>
</div>
{{#if this.settings.nostrWriteRelays}}
{{#if this.hasWriteOverrides}}
<button
type="button"
class="btn-link reset-relays"

View File

@@ -23,6 +23,7 @@ export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@service nostrData;
@service mapUi;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadActive = false;
@@ -345,9 +346,21 @@ export default class PlaceDetails extends Component {
get osmUrl() {
const id = this.place.osmId;
if (!id) return null;
const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`;
if (id) {
const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`;
}
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return null;
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
const zoom = this.mapUi.currentZoom ?? 17;
const roundedZoom = Math.round(zoom);
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
}
get gmapsUrl() {
@@ -591,7 +604,7 @@ export default class PlaceDetails extends Component {
</div>
{{#if this.osmUrl}}
{{#if this.place.osmId}}
<div class="meta-info">
<p class="content-with-icon">
<Icon @name="feather-camera" />

View File

@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
checked={{this.isSaved}}
{{on "change" this.toggleSaved}}
/>
<span class="list-color"></span>
<span class="list-color-dot"></span>
<span class="list-name">Saved places</span>
</label>
</div>
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
/>
{{! template-lint-disable no-inline-styles }}
<span
class="list-color"
class="list-color-dot"
style={{this.styleFor list.color}}
></span>
<span class="list-name">{{list.title}}</span>

View File

@@ -5,10 +5,13 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import eq from 'ember-truth-helpers/helpers/eq';
import and from 'ember-truth-helpers/helpers/and';
import not from 'ember-truth-helpers/helpers/not';
import PlaceDetails from './place-details';
import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import restoreScroll from '../modifiers/restore-scroll';
export default class PlacesSidebar extends Component {
@service storage;
@@ -160,7 +163,11 @@ export default class PlacesSidebar extends Component {
<template>
<div class="sidebar">
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
<div
class="sidebar-header
{{if this.hasHeaderPhoto 'no-border'}}
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
>
{{#if @selectedPlace}}
<button
type="button"
@@ -168,11 +175,32 @@ export default class PlacesSidebar extends Component {
{{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
{{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2>
{{#if @onBack}}
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
@name="arrow-left"
@size={{20}}
@color="#333"
/></button>
{{/if}}
{{#if @onBack}}
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon
@name="bookmark"
@size={{20}}
@color={{or @color "#898989"}}
/>
</span>
{{@title}}
</h2>
{{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
{{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2>
{{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" />
Results</h2>
{{/if}}
{{/if}}
{{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@@ -182,7 +210,7 @@ export default class PlacesSidebar extends Component {
/></button>
</div>
<div class="sidebar-content">
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @selectedPlace}}
<PlaceDetails
@place={{@selectedPlace}}
@@ -210,12 +238,10 @@ export default class PlacesSidebar extends Component {
{{humanizeOsmTag place.type}}
{{else if (eq place.source "photon")}}
{{place.description}}
{{else if (getPlaceType place.osmTags)}}
{{getPlaceType place.osmTags}}
{{else}}
{{#if place.osmTags}}
{{humanizeOsmTag (getPlaceType place.osmTags)}}
{{else if place.description}}
{{place.description}}
{{/if}}
Saved place
{{/if}}
</div>
</button>

View File

@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@service osm;
@service storage;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
this.searchTask.perform(value);
}
formatSavedPlace(place) {
const listNames = (place._listIds || [])
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
.filter(Boolean)
.join(', ');
const description = listNames
? `Saved place (${listNames})`
: 'Saved place';
return {
source: 'saved',
id: place.id,
title: place.title,
icon: 'bookmark',
description,
osmId: place.osmId,
osmType: place.osmType,
lat: place.lat,
lon: place.lon,
};
}
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
icon: 'search',
}));
// Filter saved places (minimum 3 characters)
let savedMatches = [];
if (q.length >= 3) {
savedMatches = this.storage.savedPlaces
.filter((p) => p.title && p.title.toLowerCase().includes(q))
.map((p) => this.formatSavedPlace(p));
}
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
// Deduplicate Photon results that are already in saved matches
const savedOsmIds = new Set(
savedMatches.map((s) => s.osmId).filter(Boolean)
);
const filteredPhotonResults = results.filter(
(r) => !savedOsmIds.has(r.osmId)
);
this.results = [
...categoryMatches,
...savedMatches,
...filteredPhotonResults,
];
} catch (e) {
console.error('Search failed', e);
this.results = [];
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
if (place.osmId) {
// If it's a custom saved place without an OSM ID, go to place details via internal ID
if (place.source === 'saved' && place.id && !place.osmId) {
this.router.transitionTo('place', place.id);
}
// If it has an OSM ID, go to place details via OSM ID
else 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}`;

View File

@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
// Search local bookmarks by name (minimum 3 characters)
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
let localMatches = [];
if (queryLower.length >= 3) {
localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
}
// Merge local matches
localMatches.forEach((local) => {

View File

@@ -0,0 +1,10 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [scrollTop]) => {
if (element && typeof scrollTop === 'number' && scrollTop > 0) {
// Restore inside requestAnimationFrame to guarantee layout rendering is ready
requestAnimationFrame(() => {
element.scrollTop = scrollTop;
});
}
});

View File

@@ -10,6 +10,10 @@ Router.map(function () {
this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' });
this.route('search');
this.route('menu');
this.route('lists', function () {
this.route('list', { path: '/:list_id' });
});
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});

View File

@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
activate() {
this.mapUi.clearSearchResults();
this.mapUi.hideSidebar();
}
}

10
app/routes/lists.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}

View File

@@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class ListsIndexRoute extends Route {}

17
app/routes/lists/list.js Normal file
View File

@@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
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: [] };
}
}
}

10
app/routes/menu.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class MenuRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}

View File

@@ -108,6 +108,7 @@ export default class PlaceRoute extends Route {
this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false;
this.mapUi.returnToRoute = null;
}
async loadOsmPlace(id, type = null) {

View File

@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class MapUiService extends Service {
@service nostrData;
@@ -9,6 +10,7 @@ export default class MapUiService extends Service {
@tracked isCreating = false;
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
@tracked returnToRoute = null;
@tracked currentCenter = null;
@tracked currentBounds = null;
@tracked currentZoom = null;
@@ -19,13 +21,33 @@ export default class MapUiService extends Service {
@tracked currentSearch = null;
@tracked loadingState = null;
@tracked isSidebarVisible = false;
@tracked isSidebarOpening = false;
scrollPositions = {};
@action
saveScrollPosition(key, value) {
this.scrollPositions[key] = value;
}
@action
getScrollPosition(key) {
return this.scrollPositions[key] || 0;
}
showSidebar() {
this.isSidebarVisible = true;
if (!this.isSidebarVisible) {
this.isSidebarVisible = true;
this.isSidebarOpening = true;
setTimeout(() => {
this.isSidebarOpening = false;
}, 250);
}
}
hideSidebar() {
this.isSidebarVisible = false;
this.isSidebarOpening = false;
}
selectPlace(place, options = {}) {

View File

@@ -6,7 +6,12 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
import { normalizeRelayUrl } from '../utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
uniqNormalizedRelays,
} from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [
@@ -83,43 +88,62 @@ export default class NostrDataService extends Service {
});
}
get defaultReadRelays() {
const mailboxes = (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
get requiredReadRelays() {
return DEFAULT_READ_RELAYS;
}
get defaultWriteRelays() {
const mailboxes = (this.mailboxes?.outboxes || [])
get requiredWriteRelays() {
return DEFAULT_WRITE_RELAYS;
}
get mailboxReadRelays() {
return (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get mailboxWriteRelays() {
return (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
}
get configuredReadRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxReadRelays,
...(this.settings.nostrReadRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrReadRelayExclusions || []
);
}
get configuredWriteRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxWriteRelays,
...(this.settings.nostrWriteRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrWriteRelayExclusions || []
);
}
get activeReadRelays() {
if (this.settings.nostrReadRelays) {
return Array.from(
new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultReadRelays;
return mergeRequiredRelays(
this.requiredReadRelays,
this.configuredReadRelays
);
}
get activeWriteRelays() {
if (this.settings.nostrWriteRelays) {
return Array.from(
new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultWriteRelays;
return mergeRequiredRelays(
this.requiredWriteRelays,
this.configuredWriteRelays
);
}
async loadPlacesInBounds(bbox) {

View File

@@ -9,6 +9,8 @@ const DEFAULT_SETTINGS = {
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
nostrReadRelayExclusions: null,
nostrWriteRelayExclusions: null,
experimentalEnablePhotoDeletion: false,
};
@@ -21,6 +23,9 @@ export default class SettingsService extends Service {
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
@tracked nostrWriteRelayExclusions =
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
@tracked experimentalEnablePhotoDeletion =
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
@@ -111,6 +116,8 @@ export default class SettingsService extends Service {
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
this.experimentalEnablePhotoDeletion =
finalSettings.experimentalEnablePhotoDeletion;
@@ -127,6 +134,8 @@ export default class SettingsService extends Service {
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));

View File

@@ -270,6 +270,11 @@ export default class StorageService extends Service {
}
}
async getPlacesInList(listId) {
if (!this.places || !this.places.lists) return [];
return this.places.lists.getPlaces(listId);
}
async loadPlacesInBounds(bbox) {
// 1. Calculate required prefixes
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);

View File

@@ -1,8 +1,10 @@
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
:root {
--default-list-color: #fc3;
--body-text-color: #333;
--primary-background-color: #fff;
--hover-bg: #f8f9fa;
--divider-color: #eee;
--sidebar-width: 350px;
--link-color: #2a7fff;
--link-color-visited: #6a4fbf;
@@ -10,6 +12,7 @@
--marker-color-dark: #b31412;
--danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark);
--default-list-color: #fc3;
}
html,
@@ -31,7 +34,7 @@ body {
font-family: 'Noto Sans', sans-serif;
font-size: 16px;
font-weight: normal;
color: #333;
color: var(--body-text-color);
}
#root,
@@ -112,8 +115,6 @@ body {
display: flex;
align-items: center;
grid-area: search;
/* Ensure it sits at the start of its grid area */
justify-self: start;
width: 100%;
}
@@ -127,7 +128,6 @@ body {
@media (width > 768px) {
.header-left {
/* Desktop: Ensure minimum width for search box so it's not squeezed */
min-width: 300px;
max-width: 350px;
}
@@ -140,8 +140,6 @@ body {
.header-center {
grid-area: chips;
/* Desktop: Center the chips block in the available space */
display: flex;
justify-content: center;
min-width: 0; /* Allow shrinking */
@@ -156,7 +154,6 @@ body {
}
@media (width <= 768px) {
/* No need to reset min-width/max-width since they are only set in media query above */
.header-center {
width: 100%;
overflow: hidden;
@@ -383,7 +380,7 @@ body {
display: flex;
flex-direction: column;
padding: 1rem 0;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--divider-color);
}
.account-item:last-child {
@@ -460,6 +457,11 @@ body {
display: flex;
flex-direction: column;
overflow: hidden; /* Ensure flex children are contained */
will-change: transform;
}
.sidebar-opening .sidebar {
animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.sidebar.app-menu-pane {
@@ -478,11 +480,13 @@ body {
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #eee;
height: 56px; /* Strictly enforce identical vertical height */
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */
border-bottom: 1px solid var(--divider-color);
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box; /* Guarantee strict height boundaries */
}
.sidebar-header.no-border {
@@ -491,7 +495,7 @@ body {
.sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -526,7 +530,7 @@ body {
padding-left: 1.4rem;
background: none;
border: none;
color: #333;
color: var(--body-text-color);
cursor: pointer;
text-align: left;
font-size: 0.95rem;
@@ -557,7 +561,7 @@ body {
padding-left: 1.4rem;
cursor: pointer;
font-size: 0.95rem;
color: #333;
color: var(--body-text-color);
transition: background-color 0.2s;
}
@@ -628,7 +632,7 @@ body {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fff;
background-color: var(--primary-background-color);
border: 1px solid var(--danger-color);
color: var(--danger-color);
cursor: pointer;
@@ -644,7 +648,7 @@ body {
.btn-remove-relay:hover,
.btn-remove-relay:active {
background-color: var(--danger-color);
color: #fff;
color: var(--primary-background-color);
}
.add-relay-input {
@@ -674,7 +678,7 @@ body {
margin-bottom: 1rem;
background: var(--hover-bg);
padding: 1rem;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--divider-color);
}
.form-group {
@@ -696,8 +700,8 @@ body {
font-family: inherit;
font-size: 1rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */
color: #333;
background-color: #fff;
color: var(--body-text-color);
background-color: var(--primary-background-color);
}
.form-control:focus {
@@ -708,7 +712,7 @@ body {
select.form-control {
appearance: none;
background-color: #fff;
background-color: var(--primary-background-color);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
@@ -784,7 +788,7 @@ select.form-control {
.meta-info p:first-child {
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid #eee;
border-top: 1px solid var(--divider-color);
}
.meta-info a,
@@ -843,9 +847,9 @@ abbr[title] {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid #eee;
background: #fff;
color: #333;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 1rem;
cursor: pointer;
transition: background 0.2s;
@@ -1016,7 +1020,7 @@ abbr[title] {
.photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none;
gap: 2px;
background-color: #fff;
background-color: var(--primary-background-color);
}
.photo-carousel.inline .carousel-slide {
@@ -1095,7 +1099,7 @@ abbr[title] {
.btn-outline {
background: transparent;
color: #333;
color: var(--body-text-color);
border: 1px solid #ccc;
}
@@ -1104,7 +1108,7 @@ abbr[title] {
}
.btn-secondary {
color: #333;
color: var(--body-text-color);
border: 1px solid rgb(255 204 51 / 20%);
background: rgb(255 204 51 / 30%);
}
@@ -1266,6 +1270,20 @@ span.icon {
gap: 0.5rem;
}
.content-with-icon > span:not(.icon) {
min-width: 0;
flex: 1;
}
.content-with-icon > span:not(.icon) a {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.content-with-icon .icon {
margin-top: 0.15rem;
}
@@ -1347,7 +1365,7 @@ span.icon {
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
color: #333;
color: var(--body-text-color);
pointer-events: none;
z-index: 2000;
display: none;
@@ -1406,6 +1424,8 @@ button.create-place {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
inset: auto 0 0;
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
forwards;
}
.sidebar-content {
@@ -1481,7 +1501,7 @@ button.create-place {
min-width: 0;
height: 100%;
font-size: 1rem;
color: #333;
color: var(--body-text-color);
outline: none;
width: 100%;
padding: 0 4px;
@@ -1512,7 +1532,7 @@ button.create-place {
.search-submit-btn:hover {
background: rgb(0 0 0 / 5%);
color: #333;
color: var(--body-text-color);
}
.search-clear-btn {
@@ -1530,7 +1550,7 @@ button.create-place {
.search-clear-btn:hover {
background: rgb(0 0 0 / 5%);
color: #333;
color: var(--body-text-color);
}
/* Search Results Popover */
@@ -1599,7 +1619,7 @@ button.create-place {
.result-title {
font-weight: 500;
color: #333;
color: var(--body-text-color);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
@@ -1651,7 +1671,7 @@ button.create-place {
cursor: pointer;
margin: 0;
font-size: 0.95rem;
color: #333;
color: var(--body-text-color);
}
.place-lists-manager input[type='checkbox'] {
@@ -1661,7 +1681,8 @@ button.create-place {
cursor: pointer;
}
.place-lists-manager .list-color {
/* Shared List Color Dot */
.list-color-dot {
width: 12px;
height: 12px;
background-color: var(--default-list-color);
@@ -1672,7 +1693,7 @@ button.create-place {
.place-lists-manager .divider {
height: 1px;
background: #eee;
background: var(--divider-color);
margin: 0.5rem 0;
}
@@ -1715,7 +1736,7 @@ button.create-place {
border: 1px solid #ddd;
border-radius: 16px; /* Pill shape */
font-size: 0.9rem;
color: #333;
color: var(--body-text-color);
cursor: pointer;
white-space: nowrap;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
@@ -1727,7 +1748,7 @@ button.create-place {
}
.category-chip:active {
background: #eee;
background: var(--divider-color);
}
.category-chip:disabled {
@@ -1862,7 +1883,7 @@ button.create-place {
.photo-tag-chip {
background: #f8f9fa;
color: #333;
color: var(--body-text-color);
border: none;
border-radius: 16px;
padding: 6px 12px;
@@ -1872,7 +1893,7 @@ button.create-place {
}
.photo-tag-chip:hover {
background: #eee;
background: var(--divider-color);
}
.photo-tag-chip.is-selected {
@@ -2103,7 +2124,7 @@ button.create-place {
text-align: left;
cursor: pointer;
font-size: 0.95rem;
color: #333;
color: var(--body-text-color);
white-space: nowrap;
}
@@ -2123,3 +2144,109 @@ button.create-place {
align-items: center;
justify-content: center;
}
/* Snappy slide-in from left (Desktop) */
@keyframes sidebar-slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Snappy slide-up from bottom (Mobile) */
@keyframes sidebar-slide-up-bottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Lists Index Sidebar Menu */
.lists-index-item {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: space-between;
}
.lists-index-item:hover {
background: var(--hover-bg);
}
.lists-index-item-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.lists-index-name {
font-size: 0.95rem;
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lists-index-count {
color: #666;
font-size: 0.85rem;
margin: 0;
white-space: nowrap;
flex-shrink: 0;
}
/* Centered layout when back button is present */
.sidebar-header.has-back-btn {
position: relative;
justify-content: center; /* Center horizontally */
}
/* Absolute positioning for buttons in centered header */
.sidebar-header.has-back-btn .back-btn {
position: absolute;
left: 1rem;
z-index: 10;
}
.sidebar-header.has-back-btn .close-btn {
position: absolute;
right: 1rem;
z-index: 10;
}
/* Centralized Title text */
.sidebar-header-text-centered {
position: relative;
margin: 0;
font-size: 1.2rem;
font-weight: bold;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
max-width: 60%;
}
.sidebar-header-icon-wrapper {
position: absolute;
right: 100%;
margin-right: 0.5rem;
display: flex;
align-items: center;
}

View File

@@ -2,12 +2,9 @@ import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title';
import Map from '#components/map';
import AppHeader from '#components/app-header';
import AppMenu from '#components/app-menu/index';
import Toast from '#components/toast';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { or } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component {
@@ -15,16 +12,17 @@ export default class ApplicationComponent extends Component {
@service mapUi;
@service router;
@tracked isAppMenuOpen = false;
get isSidebarOpen() {
// We consider the sidebar "open" if we are in search or place routes AND it's visible.
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
// This helps the map know if it should shift the center or adjust view.
const name = this.router.currentRouteName;
return (
this.mapUi.isSidebarVisible &&
(this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'place.new' ||
this.router.currentRouteName === 'search')
(name === 'place' ||
name === 'place.new' ||
name === 'search' ||
name === 'menu' ||
name.startsWith('lists'))
);
}
@@ -37,24 +35,27 @@ export default class ApplicationComponent extends Component {
@action
toggleAppMenu() {
this.isAppMenuOpen = !this.isAppMenuOpen;
}
@action
closeAppMenu() {
this.isAppMenuOpen = false;
if (this.router.currentRouteName === 'menu') {
this.router.transitionTo('index');
} else {
this.router.transitionTo('menu');
}
}
@action
handleOutsideClick() {
if (this.isAppMenuOpen) {
this.closeAppMenu();
} else if (
this.router.currentRouteName === 'search' ||
this.router.currentRouteName === 'place'
const name = this.router.currentRouteName;
if (
name === 'search' ||
name === 'place' ||
name === 'menu' ||
name.startsWith('lists')
) {
this.mapUi.clearSelection();
this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) {
this.router.transitionTo('index');
}
}
}
@@ -66,32 +67,30 @@ export default class ApplicationComponent extends Component {
<template>
{{pageTitle "Marco"}}
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
<div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
<div
id="rs-widget-container"
class={{if this.storage.isWidgetOpen "visible"}}
></div>
{{#if this.storage.isWidgetOpen}}
<div
class="rs-backdrop"
role="button"
{{on "click" this.storage.closeWidget}}
id="rs-widget-container"
class={{if this.storage.isWidgetOpen "visible"}}
></div>
{{/if}}
<Map
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
@onOutsideClick={{this.handleOutsideClick}}
/>
{{#if this.storage.isWidgetOpen}}
<div
class="rs-backdrop"
role="button"
{{on "click" this.storage.closeWidget}}
></div>
{{/if}}
{{#if this.isAppMenuOpen}}
<AppMenu @onClose={{this.closeAppMenu}} />
{{/if}}
<Map
@isSidebarOpen={{this.isSidebarOpen}}
@onOutsideClick={{this.handleOutsideClick}}
/>
<Toast />
<Toast />
{{outlet}}
{{outlet}}
</div>
</template>
}

1
app/templates/lists.gjs Normal file
View File

@@ -0,0 +1 @@
<template>{{outlet}}</template>

View File

@@ -0,0 +1,89 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import { htmlSafe } from '@ember/template';
export default class ListsIndexTemplate extends Component {
@service storage;
@service router;
@service mapUi;
styleFor(color) {
const finalColor =
color ||
getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
return htmlSafe(`background-color: ${finalColor}`);
}
@action
selectList(listId) {
this.router.transitionTo('lists.list', listId);
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
<template>
{{#if this.mapUi.isSidebarVisible}}
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="bookmark" @size={{20}} @color="#898989" />
</span>
Collections
</h2>
<button type="button" class="close-btn" {{on "click" this.close}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<ul class="places-list">
{{#each this.storage.lists as |list|}}
<li>
<button
type="button"
class="lists-index-item"
{{on "click" (fn this.selectList list.id)}}
>
<div class="lists-index-item-left">
{{! template-lint-disable no-inline-styles }}
<span
class="list-color-dot"
style={{this.styleFor list.color}}
></span>
<div class="lists-index-name">{{list.title}}</div>
</div>
<div class="lists-index-count">
{{#if list.placeRefs.length}}
{{list.placeRefs.length}}
places
{{else}}
empty
{{/if}}
</div>
</button>
</li>
{{/each}}
</ul>
</div>
</div>
{{/if}}
</template>
}

View File

@@ -0,0 +1,109 @@
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>
}

15
app/templates/menu.gjs Normal file
View File

@@ -0,0 +1,15 @@
import Component from '@glimmer/component';
import AppMenu from '#components/app-menu/index';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class MenuTemplate extends Component {
@service router;
@action
close() {
this.router.transitionTo('index');
}
<template><AppMenu @onClose={{this.close}} /></template>
}

View File

@@ -77,8 +77,14 @@ export default class PlaceTemplate extends Component {
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we have an active route context (e.g. lists), return to it
if (this.mapUi.returnToRoute) {
this.mapUi.showSidebar();
const { name, model } = this.mapUi.returnToRoute;
this.router.transitionTo(name, model);
}
// If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.mapUi.showSidebar();
this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,

View File

@@ -10,6 +10,10 @@ export default class SearchTemplate extends Component {
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
}
this.mapUi.returnToSearch = true;
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
@@ -28,6 +32,7 @@ export default class SearchTemplate extends Component {
{{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar
@places={{this.mapUi.searchResults}}
@scrollTop={{this.mapUi.getScrollPosition "search"}}
@onSelect={{this.selectPlace}}
@onClose={{this.close}}
/>

View File

@@ -1,4 +1,7 @@
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
/*
* Feather icons
*/
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
@@ -40,6 +43,9 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
/*
* Pinhead icons
*/
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
@@ -53,8 +59,12 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
import classicalBuildingWithClock from '@waysidemapping/pinhead/dist/icons/classical_building_with_clock.svg?raw';
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
@@ -82,8 +92,13 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -118,6 +133,9 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
/*
* Custom/local icons
*/
import loadingRing from '../icons/270-ring.svg?raw';
import nostrich from '../icons/nostrich-2.svg?raw';
import remotestorage from '../icons/remotestorage.svg?raw';
@@ -144,11 +162,13 @@ const ICONS = {
'chevron-left': chevronLeft,
'chevron-right': chevronRight,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
'city-gate': cityGate,
climbing_wall: climbingWall,
check,
'alert-circle': alertCircle,
'alert-triangle': alertTriangle,
'classical-building': classicalBuilding,
'classical-building-with-clock': classicalBuildingWithClock,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
'commercial-building': commercialBuilding,
@@ -185,6 +205,7 @@ const ICONS = {
'ice-cream-on-cone': iceCreamOnCone,
'industrial-building': industrialBuilding,
info,
'info-i': infoI,
instagram,
jewel,
'log-in': logIn,
@@ -193,7 +214,11 @@ const ICONS = {
mail,
map,
'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
'more-horizontal': moreHorizontal,
'more-vertical': moreVertical,
navigation,
@@ -245,6 +270,8 @@ const ICONS = {
winding_way_wide: windingWayWide,
parking_p: parkingP,
car,
'car-and-wrench': carAndWrench,
'castle-keep': castleKeep,
x,
zap,
'loading-ring': loadingRing,

View File

@@ -14,6 +14,30 @@ export function normalizeRelayUrl(url) {
return normalized;
}
export function uniqNormalizedRelays(relays = []) {
return Array.from(new Set(relays.map(normalizeRelayUrl).filter(Boolean)));
}
export function mergeRequiredRelays(requiredRelays = [], customRelays = []) {
const requiredSet = new Set(requiredRelays.filter(Boolean));
const merged = [...requiredRelays.filter(Boolean)];
for (const relay of uniqNormalizedRelays(customRelays)) {
if (!requiredSet.has(relay)) {
merged.push(relay);
}
}
return merged;
}
export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
const requiredSet = new Set(requiredRelays.filter(Boolean));
return uniqNormalizedRelays(customRelays).filter((relay) => {
return !requiredSet.has(relay);
});
}
/**
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.

View File

@@ -27,6 +27,8 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'bank' }, icon: 'banknote' },
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
@@ -72,6 +74,7 @@ export const POI_ICON_RULES = [
tags: { shop: 'beauty' },
icon: 'fancy-mirror-with-reflection-and-stars',
},
{ tags: { shop: 'car_repair' }, icon: 'car-and-wrench' },
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { office: true }, icon: 'commercial-building' },
@@ -103,6 +106,7 @@ export const POI_ICON_RULES = [
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
{ tags: { tourism: 'zoo' }, icon: 'camera' },
{ tags: { tourism: 'artwork' }, icon: 'camera' },
{ tags: { tourism: 'information' }, icon: 'info-i' },
{ tags: { amenity: 'cinema' }, icon: 'film' },
{ tags: { amenity: 'theatre' }, icon: 'camera' },
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
@@ -113,7 +117,9 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
{ tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },

View File

@@ -56,15 +56,24 @@ const PLACE_TYPE_KEYS = [
export function getPlaceType(tags) {
if (!tags) return null;
let fallbackKey = null;
for (const key of PLACE_TYPE_KEYS) {
const value = tags[key];
if (value) {
if (value === 'yes') {
return humanizeOsmTag(key);
if (!fallbackKey) {
fallbackKey = key;
}
continue;
}
return humanizeOsmTag(value);
}
}
if (fallbackKey) {
return humanizeOsmTag(fallbackKey);
}
return null;
}

View File

@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
label: 'Things to do',
icon: 'feather-camera',
filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
'["historic"]',
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
types: ['node', 'way', 'relation'],
},
];

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.23.0",
"version": "1.24.0",
"private": true,
"description": "Unhosted maps app",
"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

View File

@@ -39,7 +39,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DSyq2vVy.js"></script>
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
</head>
<body>

View File

@@ -49,11 +49,19 @@ export class MockNostrDataService extends Service {
return [];
}
get defaultReadRelays() {
get requiredReadRelays() {
return [];
}
get defaultWriteRelays() {
get requiredWriteRelays() {
return [];
}
get mailboxReadRelays() {
return [];
}
get mailboxWriteRelays() {
return [];
}

View File

@@ -0,0 +1,188 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import Service, { service } from '@ember/service';
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
class MockNostrDataService extends Service {
@service settings;
requiredReadRelays = ['wss://nostr.kosmos.org'];
requiredWriteRelays = [];
mailboxReadRelays = ['wss://mailbox.example.com'];
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
get configuredReadRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxReadRelays,
...(this.settings.nostrReadRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrReadRelayExclusions || []
);
}
get configuredWriteRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxWriteRelays,
...(this.settings.nostrWriteRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrWriteRelayExclusions || []
);
}
get activeReadRelays() {
return mergeRequiredRelays(
this.requiredReadRelays,
this.configuredReadRelays
);
}
get activeWriteRelays() {
return mergeRequiredRelays(
this.requiredWriteRelays,
this.configuredWriteRelays
);
}
async clearCache() {}
}
function readRows(element) {
const list = element.querySelectorAll('.relay-list')[0];
return [...list.querySelectorAll('li')];
}
function writeRows(element) {
const list = element.querySelectorAll('.relay-list')[1];
return [...list.querySelectorAll('li')];
}
function rowByText(rows, text) {
return rows.find((row) => row.textContent.includes(text));
}
module('Integration | Component | app-menu/settings/nostr', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
localStorage.removeItem('marco:settings');
this.owner.register('service:nostrData', MockNostrDataService);
this.settings = this.owner.lookup('service:settings');
this.onChange = () => {};
});
hooks.afterEach(function () {
localStorage.removeItem('marco:settings');
});
async function renderAndOpenDetails(context) {
await render(
<template><AppMenuSettingsNostr @onChange={{this.onChange}} /></template>
);
await click('summary');
return context.element;
}
test('required read relay is first and non-removable', async function (assert) {
const element = await renderAndOpenDetails(this);
const rows = readRows(element);
assert.dom(rows[0]).includesText('nostr.kosmos.org');
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
const mailboxRow = rowByText(rows, 'mailbox.example.com');
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
});
test('removing mailbox read relay stores exclusion override', async function (assert) {
const element = await renderAndOpenDetails(this);
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
await click(mailboxRow.querySelector('.btn-remove-relay'));
assert.deepEqual(this.settings.nostrReadRelayExclusions, [
'wss://mailbox.example.com',
]);
assert.strictEqual(this.settings.nostrReadRelays, null);
});
test('removing custom read relay updates custom list without exclusions', async function (assert) {
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
const element = await renderAndOpenDetails(this);
const customRow = rowByText(readRows(element), 'custom.example.com');
await click(customRow.querySelector('.btn-remove-relay'));
assert.strictEqual(this.settings.nostrReadRelays, null);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
});
test('adding read relay clears existing exclusion for same relay', async function (assert) {
this.settings.update('nostrReadRelayExclusions', [
'wss://mailbox.example.com',
]);
const element = await renderAndOpenDetails(this);
await fillIn('#new-read-relay', 'Mailbox.EXAMPLE.com/');
await click(element.querySelector('#new-read-relay').nextElementSibling);
assert.deepEqual(this.settings.nostrReadRelays, [
'wss://mailbox.example.com',
]);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
});
test('reset read relays clears additions and exclusions', async function (assert) {
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
this.settings.update('nostrReadRelayExclusions', [
'wss://mailbox.example.com',
]);
const element = await renderAndOpenDetails(this);
await click(element.querySelectorAll('.reset-relays')[0]);
assert.strictEqual(this.settings.nostrReadRelays, null);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
assert.dom(requiredRow).exists();
});
test('write relays are removable and mailbox delete stores exclusion', async function (assert) {
this.settings.update('nostrWriteRelays', [
'wss://custom-write.example.com',
]);
const element = await renderAndOpenDetails(this);
const rows = writeRows(element);
assert.true(
rows.every((row) => row.querySelector('.btn-remove-relay')),
'all write relays can be removed'
);
const mailboxRow = rowByText(rows, 'mailbox-write.example.com');
await click(mailboxRow.querySelector('.btn-remove-relay'));
assert.deepEqual(this.settings.nostrWriteRelayExclusions, [
'wss://mailbox-write.example.com',
]);
});
});

View File

@@ -334,4 +334,54 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom(links[0]).hasText('+44 987 654 321');
assert.dom(links[1]).hasText('+1 234-567 8900');
});
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
const place = {
title: 'OSM Place',
osmId: '12345',
osmType: 'node',
lat: 52.520008,
lon: 13.404954,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const osmLink = this.element.querySelector(
'.meta-info a[href^="https://www.openstreetmap.org/node/12345"]'
);
assert.ok(osmLink, 'OpenStreetMap link is rendered');
assert.strictEqual(
osmLink.getAttribute('href'),
'https://www.openstreetmap.org/node/12345'
);
assert.dom('button.btn-link').hasText('Add a photo');
});
test('it renders correct search-based OpenStreetMap link for a custom saved place', async function (assert) {
class MockMapUi extends Service {
currentCenter = { lat: 52.5, lon: 13.4 };
currentZoom = 15.6;
}
this.owner.register('service:map-ui', MockMapUi);
const place = {
title: 'Custom Place',
lat: 52.520008,
lon: 13.404954,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const osmLink = this.element.querySelector(
'.meta-info a[href^="https://www.openstreetmap.org/search"]'
);
assert.ok(osmLink, 'OpenStreetMap search link is rendered');
assert.strictEqual(
osmLink.getAttribute('href'),
'https://www.openstreetmap.org/search?lat=52.520008&lon=13.404954&zoom=16#map=16/52.50000/13.40000'
);
assert.dom('button.btn-link').doesNotExist();
});
});

View File

@@ -230,4 +230,279 @@ module('Integration | Component | search-box', function (hooks) {
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
]);
});
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'coffee') {
return [
{
title: 'Awesome Coffee',
osmId: '999',
osmType: 'node',
description: 'Duplicate to be removed',
},
{
title: 'Other Coffee',
osmId: '888',
osmType: 'node',
description: 'A different coffee shop',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "coffee" to trigger matches in Category, Saved, and Photon
await fillIn('.search-input', 'coffee');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should be exactly 3 items:
// 1. Category (Coffee)
// 2. Saved (Awesome Coffee)
// 3. Photon (Other Coffee)
// (The Photon duplicate of "Awesome Coffee" is removed)
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
// 1. Category
assert.ok(
resultItems[0].textContent.includes('Coffee'),
'First item is the category match'
);
assert
.dom(resultItems[0].querySelector('.result-icon svg'))
.hasClass('feather-search', 'Category uses search icon');
// 2. Saved Place
assert.ok(
resultItems[1].textContent.includes('Awesome Coffee'),
'Second item is the saved place match'
);
assert.ok(
resultItems[1].textContent.includes('Saved place'),
'Saved place has correct description text'
);
assert
.dom(resultItems[1].querySelector('.result-icon svg'))
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
// 3. Photon Match
assert.ok(
resultItems[2].textContent.includes('Other Coffee'),
'Third item is the unique photon result'
);
// Click the Saved Place
await click(resultItems[1]);
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
});
test('it requires 3 or more characters to match saved places', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo() {}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'aw' || query === 'awe') {
return [
{
title: 'Aww Some Place',
osmId: '111',
osmType: 'node',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "aw" (2 characters)
await fillIn('.search-input', 'aw');
await waitFor('.search-results-popover', { timeout: 2000 });
let resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should only show Photon match since 'aw' is < 3 characters
assert.strictEqual(
resultItems.length,
1,
'Renders exactly 1 item for 2 chars'
);
assert.ok(
resultItems[0].textContent.includes('Aww Some Place'),
'Shows photon match'
);
assert.notOk(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is NOT shown for 2 char query'
);
// Type "awe" (3 characters)
await fillIn('.search-input', 'awe');
await waitFor('.search-results-popover', { timeout: 2000 });
resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should now show Saved Place and Photon match
assert.strictEqual(
resultItems.length,
2,
'Renders exactly 2 items for 3 chars'
);
assert.ok(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is now shown'
);
assert.ok(
resultItems.some((item) =>
item.textContent.includes('Saved place (Favorites)')
),
'List names are appended to the description'
);
});
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service (Custom Place)
class MockStorageService extends Service {
savedPlaces = [
{
id: 'custom-1234',
title: 'My Custom Home',
lat: 52.5,
lon: 13.4,
// Notice NO osmId or osmType
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type 3 chars to trigger saved place match
await fillIn('.search-input', 'cus');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Ensure our custom place is rendered
const customResult = resultItems.find((item) =>
item.textContent.includes('My Custom Home')
);
assert.ok(customResult, 'Custom place is rendered');
// Click it
await click(customResult);
// Verify it navigated using the internal ID, NOT a search query
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
});
});

View File

@@ -1,5 +1,11 @@
import { module, test } from 'qunit';
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
parsePlacePhotos,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
module('Unit | Utility | nostr', function () {
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
@@ -141,4 +147,59 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([
'Relay.example.com',
'wss://relay.example.com/',
'wss://other.example.com',
]);
assert.deepEqual(relays, [
'wss://relay.example.com',
'wss://other.example.com',
]);
});
test('mergeRequiredRelays keeps required relays as-is and merges normalized custom relays', function (assert) {
const relays = mergeRequiredRelays(
['wss://required.example.com', 'required-2.example.com'],
['required-2.example.com/', 'wss://custom.example.com']
);
assert.deepEqual(relays, [
'wss://required.example.com',
'required-2.example.com',
'wss://required-2.example.com',
'wss://custom.example.com',
]);
});
test('excludeRequiredRelays removes required relays from normalized custom list', function (assert) {
const relays = excludeRequiredRelays(
[
'wss://required.example.com',
'custom.example.com',
'ws://custom2.example.com',
],
['wss://required.example.com']
);
assert.deepEqual(relays, [
'wss://custom.example.com',
'ws://custom2.example.com',
]);
});
test('excludeRequiredRelays trusts required list without normalizing it', function (assert) {
const relays = excludeRequiredRelays(
['Required.example.com', 'custom.example.com'],
['required.example.com']
);
assert.deepEqual(relays, [
'wss://required.example.com',
'wss://custom.example.com',
]);
});
});

View File

@@ -1,4 +1,5 @@
import { getIconNameForTags } from 'marco/utils/osm-icons';
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons';
import { getIcon } from 'marco/utils/icons';
import { module, test } from 'qunit';
module('Unit | Utility | osm-icons', function () {
@@ -36,4 +37,14 @@ module('Unit | Utility | osm-icons', function () {
let result = getIconNameForTags({ foo: 'bar' });
assert.strictEqual(result, null);
});
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
for (let rule of POI_ICON_RULES) {
let icon = getIcon(rule.icon);
assert.ok(
icon,
`Icon "${rule.icon}" specified in POI_ICON_RULES should be imported and available in getIcon`
);
}
});
});

View File

@@ -89,6 +89,12 @@ module('Unit | Utility | osm', function (hooks) {
assert.strictEqual(result, 'Building');
});
test('getPlaceType ignores generic "yes" values if a more specific tag is present', function (assert) {
const tags = { historic: 'yes', building: 'tower' };
const result = getPlaceType(tags);
assert.strictEqual(result, 'Tower');
});
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
// If something is both a shop and a building, it should be a shop
const tags = { building: 'yes', shop: 'supermarket' };