Compare commits

..

1 Commits

Author SHA1 Message Date
raucao f0a19e30b8 Don't load actual map tiles in tests
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Failing after 1m6s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
We don't need remote tiles for testing our functionality
2026-06-06 12:34:59 +04:00
46 changed files with 244 additions and 1561 deletions
+2 -2
View File
@@ -3,11 +3,11 @@ import Icon from '#components/icon';
<template> <template>
{{! template-lint-disable no-nested-interactive }} {{! template-lint-disable no-nested-interactive }}
<div class="sidebar-header has-back-btn"> <div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}> <button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" /> <Icon @name="arrow-left" @size={{20}} @color="#333" />
</button> </button>
<h2 class="sidebar-header-text-centered">About</h2> <h2>About</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}> <button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" /> <Icon @name="x" @size={{20}} @color="#333" />
</button> </button>
-6
View File
@@ -19,12 +19,6 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<div class="sidebar-content"> <div class="sidebar-content">
<ul class="app-menu"> <ul class="app-menu">
<li>
<button type="button" {{on "click" @onSavedPlaces}}>
<Icon @name="bookmark" @size={{20}} />
<span>Collections</span>
</button>
</li>
<li> <li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}> <button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} /> <Icon @name="settings" @size={{20}} />
+1 -12
View File
@@ -2,7 +2,6 @@ import Component from '@glimmer/component';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { service } from '@ember/service';
import eq from 'ember-truth-helpers/helpers/eq'; import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuHome from './home'; import AppMenuHome from './home';
@@ -10,7 +9,6 @@ import AppMenuSettings from './settings';
import AppMenuAbout from './about'; import AppMenuAbout from './about';
export default class AppMenu extends Component { export default class AppMenu extends Component {
@service router;
@tracked currentView = 'menu'; // 'menu', 'settings', 'about' @tracked currentView = 'menu'; // 'menu', 'settings', 'about'
@action @action
@@ -18,19 +16,10 @@ export default class AppMenu extends Component {
this.currentView = view; this.currentView = view;
} }
@action
goToSavedPlaces() {
this.router.transitionTo('lists.index');
}
<template> <template>
<div class="sidebar app-menu-pane"> <div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}} {{#if (eq this.currentView "menu")}}
<AppMenuHome <AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
@onNavigate={{this.setView}}
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
/>
{{else if (eq this.currentView "settings")}} {{else if (eq this.currentView "settings")}}
<AppMenuSettings <AppMenuSettings
+2 -2
View File
@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
} }
<template> <template>
<div class="sidebar-header has-back-btn"> <div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}> <button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" /> <Icon @name="arrow-left" @size={{20}} @color="#333" />
</button> </button>
<h2 class="sidebar-header-text-centered">Settings</h2> <h2>Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}> <button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" /> <Icon @name="x" @size={{20}} @color="#333" />
</button> </button>
+25 -167
View File
@@ -5,11 +5,7 @@ import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import Icon from '#components/icon'; import Icon from '#components/icon';
import { import { normalizeRelayUrl } from '../../../utils/nostr';
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
} from '../../../utils/nostr';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : ''); const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
@@ -21,74 +17,6 @@ export default class AppMenuSettingsNostr extends Component {
@tracked newReadRelay = ''; @tracked newReadRelay = '';
@tracked newWriteRelay = ''; @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 @action
updateNewReadRelay(event) { updateNewReadRelay(event) {
this.newReadRelay = event.target.value; this.newReadRelay = event.target.value;
@@ -104,51 +32,19 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newReadRelay); const url = normalizeRelayUrl(this.newReadRelay);
if (!url) return; if (!url) return;
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [ const current =
...this.customReadRelays, this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
url, const set = new Set([...current, url]);
]); this.settings.update('nostrReadRelays', Array.from(set));
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 = ''; this.newReadRelay = '';
} }
@action @action
removeReadRelay(url) { removeReadRelay(url) {
if (this.requiredReadRelaySet.has(url)) { const current =
return; this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
} const filtered = current.filter((r) => r !== url);
this.settings.update('nostrReadRelays', filtered);
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 @action
@@ -168,7 +64,6 @@ export default class AppMenuSettingsNostr extends Component {
@action @action
resetReadRelays() { resetReadRelays() {
this.settings.update('nostrReadRelays', null); this.settings.update('nostrReadRelays', null);
this.settings.update('nostrReadRelayExclusions', null);
} }
@action @action
@@ -176,57 +71,24 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newWriteRelay); const url = normalizeRelayUrl(this.newWriteRelay);
if (!url) return; if (!url) return;
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [ const current =
...this.customWriteRelays, this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
url, const set = new Set([...current, url]);
]); this.settings.update('nostrWriteRelays', Array.from(set));
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 = ''; this.newWriteRelay = '';
} }
@action @action
removeWriteRelay(url) { removeWriteRelay(url) {
if (this.requiredWriteRelaySet.has(url)) { const current =
return; this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
} const filtered = current.filter((r) => r !== url);
this.settings.update('nostrWriteRelays', filtered);
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 @action
resetWriteRelays() { resetWriteRelays() {
this.settings.update('nostrWriteRelays', null); this.settings.update('nostrWriteRelays', null);
this.settings.update('nostrWriteRelayExclusions', null);
} }
@action @action
@@ -250,20 +112,18 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group"> <div class="form-group">
<label for="new-read-relay">Read Relays</label> <label for="new-read-relay">Read Relays</label>
<ul class="relay-list"> <ul class="relay-list">
{{#each this.readRelaysForDisplay as |relay|}} {{#each this.nostrData.activeReadRelays as |relay|}}
<li> <li>
<span>{{stripProtocol relay.url}}</span> <span>{{stripProtocol relay}}</span>
{{#unless relay.isRequired}}
<button <button
type="button" type="button"
class="btn-remove-relay" class="btn-remove-relay"
title="Remove relay" title="Remove relay"
aria-label="Remove" aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay.url)}} {{on "click" (fn this.removeReadRelay relay)}}
> >
<Icon @name="x" @size={{14}} @color="currentColor" /> <Icon @name="x" @size={{14}} @color="currentColor" />
</button> </button>
{{/unless}}
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
@@ -283,7 +143,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addReadRelay}} {{on "click" this.addReadRelay}}
>Add</button> >Add</button>
</div> </div>
{{#if this.hasReadOverrides}} {{#if this.settings.nostrReadRelays}}
<button <button
type="button" type="button"
class="btn-link reset-relays" class="btn-link reset-relays"
@@ -297,20 +157,18 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group"> <div class="form-group">
<label for="new-write-relay">Write Relays</label> <label for="new-write-relay">Write Relays</label>
<ul class="relay-list"> <ul class="relay-list">
{{#each this.writeRelaysForDisplay as |relay|}} {{#each this.nostrData.activeWriteRelays as |relay|}}
<li> <li>
<span>{{stripProtocol relay.url}}</span> <span>{{stripProtocol relay}}</span>
{{#unless relay.isRequired}}
<button <button
type="button" type="button"
class="btn-remove-relay" class="btn-remove-relay"
title="Remove relay" title="Remove relay"
aria-label="Remove" aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay.url)}} {{on "click" (fn this.removeWriteRelay relay)}}
> >
<Icon @name="x" @size={{14}} @color="currentColor" /> <Icon @name="x" @size={{14}} @color="currentColor" />
</button> </button>
{{/unless}}
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
@@ -330,7 +188,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addWriteRelay}} {{on "click" this.addWriteRelay}}
>Add</button> >Add</button>
</div> </div>
{{#if this.hasWriteOverrides}} {{#if this.settings.nostrWriteRelays}}
<button <button
type="button" type="button"
class="btn-link reset-relays" class="btn-link reset-relays"
+18
View File
@@ -20,6 +20,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
import { apply } from 'ol-mapbox-style'; import { apply } from 'ol-mapbox-style';
import { getIcon } from '../utils/icons'; import { getIcon } from '../utils/icons';
import { getIconNameForTags } from '../utils/osm-icons'; import { getIconNameForTags } from '../utils/osm-icons';
import config from 'marco/config/environment';
export default class MapComponent extends Component { export default class MapComponent extends Component {
@service osm; @service osm;
@@ -286,9 +287,26 @@ export default class MapComponent extends Component {
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]); this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
this.mapUi.updateZoom(view.getZoom()); this.mapUi.updateZoom(view.getZoom());
if (config.environment === 'test') {
apply(this.mapInstance, {
version: 8,
name: 'Test Style',
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#f8f4f0',
},
},
],
});
} else {
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', { apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
webfonts: 'data:text/css,', webfonts: 'data:text/css,',
}); });
}
this.searchOverlayElement = document.createElement('div'); this.searchOverlayElement = document.createElement('div');
this.searchOverlayElement.className = 'search-pulse'; this.searchOverlayElement.className = 'search-pulse';
+2 -15
View File
@@ -23,7 +23,6 @@ export default class PlaceDetails extends Component {
@service storage; @service storage;
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service mapUi;
@tracked isEditing = false; @tracked isEditing = false;
@tracked showLists = false; @tracked showLists = false;
@tracked isPhotoUploadActive = false; @tracked isPhotoUploadActive = false;
@@ -346,23 +345,11 @@ export default class PlaceDetails extends Component {
get osmUrl() { get osmUrl() {
const id = this.place.osmId; const id = this.place.osmId;
if (id) { if (!id) return null;
const type = this.place.osmType || 'node'; const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`; 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() { get gmapsUrl() {
const id = this.place.gmapsId || this.place.osmId; const id = this.place.gmapsId || this.place.osmId;
if (!id) return null; if (!id) return null;
@@ -604,7 +591,7 @@ export default class PlaceDetails extends Component {
</div> </div>
{{#if this.place.osmId}} {{#if this.osmUrl}}
<div class="meta-info"> <div class="meta-info">
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="feather-camera" /> <Icon @name="feather-camera" />
+2 -2
View File
@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
checked={{this.isSaved}} checked={{this.isSaved}}
{{on "change" this.toggleSaved}} {{on "change" this.toggleSaved}}
/> />
<span class="list-color-dot"></span> <span class="list-color"></span>
<span class="list-name">Saved places</span> <span class="list-name">Saved places</span>
</label> </label>
</div> </div>
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
/> />
{{! template-lint-disable no-inline-styles }} {{! template-lint-disable no-inline-styles }}
<span <span
class="list-color-dot" class="list-color"
style={{this.styleFor list.color}} style={{this.styleFor list.color}}
></span> ></span>
<span class="list-name">{{list.title}}</span> <span class="list-name">{{list.title}}</span>
+8 -40
View File
@@ -5,13 +5,10 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or'; import or from 'ember-truth-helpers/helpers/or';
import eq from 'ember-truth-helpers/helpers/eq'; 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 PlaceDetails from './place-details';
import Icon from './icon'; import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag'; import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { getLocalizedName, getPlaceType } from '../utils/osm'; import { getLocalizedName, getPlaceType } from '../utils/osm';
import restoreScroll from '../modifiers/restore-scroll';
export default class PlacesSidebar extends Component { export default class PlacesSidebar extends Component {
@service storage; @service storage;
@@ -163,44 +160,19 @@ export default class PlacesSidebar extends Component {
<template> <template>
<div class="sidebar"> <div class="sidebar">
<div <div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
class="sidebar-header
{{if this.hasHeaderPhoto 'no-border'}}
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
>
{{#if @selectedPlace}} {{#if @selectedPlace}}
<button <button
type="button" type="button"
class="back-btn" class="back-btn"
{{on "click" this.clearSelection}} {{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
{{#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}} {{else}}
{{#if this.isNearbySearch}} {{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> <h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2> Nearby</h2>
{{else}} {{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" /> <h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
Results</h2>
{{/if}}
{{/if}} {{/if}}
{{/if}} {{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon <button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@@ -210,18 +182,13 @@ export default class PlacesSidebar extends Component {
/></button> /></button>
</div> </div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}> <div class="sidebar-content">
{{#if @selectedPlace}} {{#if @selectedPlace}}
<PlaceDetails <PlaceDetails
@place={{@selectedPlace}} @place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}} @onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}} @onSave={{this.updateBookmark}}
/> />
{{else}}
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else}} {{else}}
{{#if @places}} {{#if @places}}
<ul class="places-list"> <ul class="places-list">
@@ -243,10 +210,12 @@ export default class PlacesSidebar extends Component {
{{humanizeOsmTag place.type}} {{humanizeOsmTag place.type}}
{{else if (eq place.source "photon")}} {{else if (eq place.source "photon")}}
{{place.description}} {{place.description}}
{{else if (getPlaceType place.osmTags)}}
{{getPlaceType place.osmTags}}
{{else}} {{else}}
Saved place {{#if place.osmTags}}
{{humanizeOsmTag (getPlaceType place.osmTags)}}
{{else if place.description}}
{{place.description}}
{{/if}}
{{/if}} {{/if}}
</div> </div>
</button> </button>
@@ -270,7 +239,6 @@ export default class PlacesSidebar extends Component {
Create new place Create new place
</button> </button>
{{/if}} {{/if}}
{{/if}}
</div> </div>
</div> </div>
</template> </template>
-108
View File
@@ -1,108 +0,0 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class ListsListController extends Controller {
@service router;
@service mapUi;
@service storage;
@tracked model;
@tracked loadedPlaces = [];
get listId() {
return this.model?.list_id;
}
loadPlacesTask = task({ restartable: true }, async (listId) => {
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
try {
this.loadedPlaces = await this.storage.getPlacesInList(listId);
} catch (e) {
console.error('Failed to load places in list', listId, e);
this.loadedPlaces = [];
}
});
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 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 background-fetched places that are still valid but not in live state yet
this.loadedPlaces.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');
}
}
+1 -18
View File
@@ -20,24 +20,7 @@ export default class SearchController extends Controller {
category = null; category = null;
fetchResultsTask = task({ restartable: true }, async (params) => { fetchResultsTask = task({ restartable: true }, async (params) => {
// 1. Check if the incoming parameters match our currently loaded search // Hide sidebar and clear previous results immediately to signal a new search
const isSameSearch =
this.mapUi.currentSearch &&
params.q === this.mapUi.currentSearch.q &&
params.category === this.mapUi.currentSearch.category &&
params.lat === this.mapUi.currentSearch.lat &&
params.lon === this.mapUi.currentSearch.lon;
const hasResults =
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
if (isSameSearch && hasResults) {
this.mapUi.showSidebar();
return;
}
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
this.mapUi.clearSearchResults(); this.mapUi.clearSearchResults();
-10
View File
@@ -1,10 +0,0 @@
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;
});
}
});
-4
View File
@@ -10,10 +10,6 @@ Router.map(function () {
this.route('place', { path: '/place/:place_id' }); this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' }); this.route('place.new', { path: '/place/new' });
this.route('search'); this.route('search');
this.route('menu');
this.route('lists', function () {
this.route('list', { path: '/:list_id' });
});
this.route('oauth', function () { this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' }); this.route('osm-callback', { path: '/osm/callback' });
}); });
-1
View File
@@ -6,6 +6,5 @@ export default class IndexRoute extends Route {
activate() { activate() {
this.mapUi.clearSearchResults(); this.mapUi.clearSearchResults();
this.mapUi.hideSidebar();
} }
} }
-10
View File
@@ -1,10 +0,0 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}
-3
View File
@@ -1,3 +0,0 @@
import Route from '@ember/routing/route';
export default class ListsIndexRoute extends Route {}
-26
View File
@@ -1,26 +0,0 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ListsListRoute extends Route {
@service storage;
model(params) {
// Resolve instantly so transition happens in 0ms!
return { list_id: params.list_id };
}
setupController(controller, model) {
console.debug('DEBUG: setupController controller is:', controller);
console.debug(
'DEBUG: controller.loadPlacesTask is:',
controller?.loadPlacesTask
);
controller.model = model;
super.setupController(controller, model);
if (controller && controller.loadPlacesTask) {
controller.loadPlacesTask.perform(model.list_id);
} else {
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
}
}
}
-10
View File
@@ -1,10 +0,0 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class MenuRoute extends Route {
@service mapUi;
activate() {
this.mapUi.showSidebar();
}
}
-1
View File
@@ -108,7 +108,6 @@ export default class PlaceRoute extends Route {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations // Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false; this.mapUi.returnToSearch = false;
this.mapUi.returnToRoute = null;
} }
async loadOsmPlace(id, type = null) { async loadOsmPlace(id, type = null) {
-22
View File
@@ -1,6 +1,5 @@
import Service, { service } from '@ember/service'; import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class MapUiService extends Service { export default class MapUiService extends Service {
@service nostrData; @service nostrData;
@@ -10,7 +9,6 @@ export default class MapUiService extends Service {
@tracked isCreating = false; @tracked isCreating = false;
@tracked creationCoordinates = null; @tracked creationCoordinates = null;
@tracked returnToSearch = false; @tracked returnToSearch = false;
@tracked returnToRoute = null;
@tracked currentCenter = null; @tracked currentCenter = null;
@tracked currentBounds = null; @tracked currentBounds = null;
@tracked currentZoom = null; @tracked currentZoom = null;
@@ -21,33 +19,13 @@ export default class MapUiService extends Service {
@tracked currentSearch = null; @tracked currentSearch = null;
@tracked loadingState = null; @tracked loadingState = null;
@tracked isSidebarVisible = false; @tracked isSidebarVisible = false;
@tracked isSidebarOpening = false;
scrollPositions = {};
@action
saveScrollPosition(key, value) {
this.scrollPositions[key] = value;
}
@action
getScrollPosition(key) {
return this.scrollPositions[key] || 0;
}
showSidebar() { showSidebar() {
if (!this.isSidebarVisible) {
this.isSidebarVisible = true; this.isSidebarVisible = true;
this.isSidebarOpening = true;
setTimeout(() => {
this.isSidebarOpening = false;
}, 250);
}
} }
hideSidebar() { hideSidebar() {
this.isSidebarVisible = false; this.isSidebarVisible = false;
this.isSidebarOpening = false;
} }
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
+24 -48
View File
@@ -6,12 +6,7 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers'; import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache'; import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb'; import { NostrIDB, openDB } from 'nostr-idb';
import { import { normalizeRelayUrl } from '../utils/nostr';
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
uniqNormalizedRelays,
} from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [ const DIRECTORY_RELAYS = [
@@ -88,63 +83,44 @@ export default class NostrDataService extends Service {
}); });
} }
get requiredReadRelays() { get defaultReadRelays() {
return DEFAULT_READ_RELAYS; const mailboxes = (this.mailboxes?.inboxes || [])
}
get requiredWriteRelays() {
return DEFAULT_WRITE_RELAYS;
}
get mailboxReadRelays() {
return (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl) .map(normalizeRelayUrl)
.filter(Boolean); .filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
} }
get mailboxWriteRelays() { get defaultWriteRelays() {
return (this.mailboxes?.outboxes || []) const mailboxes = (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl) .map(normalizeRelayUrl)
.filter(Boolean); .filter(Boolean);
} const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
get configuredReadRelays() { return Array.from(new Set([...defaults, ...mailboxes]));
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() { get activeReadRelays() {
return mergeRequiredRelays( if (this.settings.nostrReadRelays) {
this.requiredReadRelays, return Array.from(
this.configuredReadRelays new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
); );
} }
return this.defaultReadRelays;
}
get activeWriteRelays() { get activeWriteRelays() {
return mergeRequiredRelays( if (this.settings.nostrWriteRelays) {
this.requiredWriteRelays, return Array.from(
this.configuredWriteRelays new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
); );
} }
return this.defaultWriteRelays;
}
async loadPlacesInBounds(bbox) { async loadPlacesInBounds(bbox) {
const requiredPrefixes = getGeohashPrefixesInBbox(bbox); const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
-9
View File
@@ -9,8 +9,6 @@ const DEFAULT_SETTINGS = {
nostrPhotoFallbackUploads: false, nostrPhotoFallbackUploads: false,
nostrReadRelays: null, nostrReadRelays: null,
nostrWriteRelays: null, nostrWriteRelays: null,
nostrReadRelayExclusions: null,
nostrWriteRelayExclusions: null,
experimentalEnablePhotoDeletion: false, experimentalEnablePhotoDeletion: false,
}; };
@@ -23,9 +21,6 @@ export default class SettingsService extends Service {
DEFAULT_SETTINGS.nostrPhotoFallbackUploads; DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays; @tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays; @tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
@tracked nostrWriteRelayExclusions =
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
@tracked experimentalEnablePhotoDeletion = @tracked experimentalEnablePhotoDeletion =
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion; DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
@@ -116,8 +111,6 @@ export default class SettingsService extends Service {
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads; this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays; this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays; this.nostrWriteRelays = finalSettings.nostrWriteRelays;
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
this.experimentalEnablePhotoDeletion = this.experimentalEnablePhotoDeletion =
finalSettings.experimentalEnablePhotoDeletion; finalSettings.experimentalEnablePhotoDeletion;
@@ -134,8 +127,6 @@ export default class SettingsService extends Service {
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads, nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays, nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays, nostrWriteRelays: this.nostrWriteRelays,
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion, experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
}; };
localStorage.setItem('marco:settings', JSON.stringify(settings)); localStorage.setItem('marco:settings', JSON.stringify(settings));
-5
View File
@@ -270,11 +270,6 @@ 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) { async loadPlacesInBounds(bbox) {
// 1. Calculate required prefixes // 1. Calculate required prefixes
const requiredPrefixes = getGeohashPrefixesInBbox(bbox); const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
+40 -175
View File
@@ -1,10 +1,8 @@
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ /* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
:root { :root {
--body-text-color: #333; --default-list-color: #fc3;
--primary-background-color: #fff;
--hover-bg: #f8f9fa; --hover-bg: #f8f9fa;
--divider-color: #eee;
--sidebar-width: 350px; --sidebar-width: 350px;
--link-color: #2a7fff; --link-color: #2a7fff;
--link-color-visited: #6a4fbf; --link-color-visited: #6a4fbf;
@@ -12,7 +10,6 @@
--marker-color-dark: #b31412; --marker-color-dark: #b31412;
--danger-color: var(--marker-color-primary); --danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark); --danger-color-dark: var(--marker-color-dark);
--default-list-color: #fc3;
} }
html, html,
@@ -34,7 +31,7 @@ body {
font-family: 'Noto Sans', sans-serif; font-family: 'Noto Sans', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: normal; font-weight: normal;
color: var(--body-text-color); color: #333;
} }
#root, #root,
@@ -115,6 +112,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
grid-area: search; grid-area: search;
/* Ensure it sits at the start of its grid area */
justify-self: start; justify-self: start;
width: 100%; width: 100%;
} }
@@ -128,6 +127,7 @@ body {
@media (width > 768px) { @media (width > 768px) {
.header-left { .header-left {
/* Desktop: Ensure minimum width for search box so it's not squeezed */
min-width: 300px; min-width: 300px;
max-width: 350px; max-width: 350px;
} }
@@ -140,6 +140,8 @@ body {
.header-center { .header-center {
grid-area: chips; grid-area: chips;
/* Desktop: Center the chips block in the available space */
display: flex; display: flex;
justify-content: center; justify-content: center;
min-width: 0; /* Allow shrinking */ min-width: 0; /* Allow shrinking */
@@ -154,6 +156,7 @@ body {
} }
@media (width <= 768px) { @media (width <= 768px) {
/* No need to reset min-width/max-width since they are only set in media query above */
.header-center { .header-center {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -380,7 +383,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1rem 0; padding: 1rem 0;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid #eee;
} }
.account-item:last-child { .account-item:last-child {
@@ -457,11 +460,6 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; /* Ensure flex children are contained */ 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 { .sidebar.app-menu-pane {
@@ -480,13 +478,11 @@ body {
} }
.sidebar-header { .sidebar-header {
height: 56px; /* Strictly enforce identical vertical height */ padding: 1rem;
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */ border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--divider-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-sizing: border-box; /* Guarantee strict height boundaries */
} }
.sidebar-header.no-border { .sidebar-header.no-border {
@@ -495,7 +491,7 @@ body {
.sidebar-header h2 { .sidebar-header h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -530,7 +526,7 @@ body {
padding-left: 1.4rem; padding-left: 1.4rem;
background: none; background: none;
border: none; border: none;
color: var(--body-text-color); color: #333;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font-size: 0.95rem; font-size: 0.95rem;
@@ -561,7 +557,7 @@ body {
padding-left: 1.4rem; padding-left: 1.4rem;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--body-text-color); color: #333;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
@@ -632,7 +628,7 @@ body {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
background-color: var(--primary-background-color); background-color: #fff;
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
color: var(--danger-color); color: var(--danger-color);
cursor: pointer; cursor: pointer;
@@ -648,7 +644,7 @@ body {
.btn-remove-relay:hover, .btn-remove-relay:hover,
.btn-remove-relay:active { .btn-remove-relay:active {
background-color: var(--danger-color); background-color: var(--danger-color);
color: var(--primary-background-color); color: #fff;
} }
.add-relay-input { .add-relay-input {
@@ -678,7 +674,7 @@ body {
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--hover-bg); background: var(--hover-bg);
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid #eee;
} }
.form-group { .form-group {
@@ -700,8 +696,8 @@ body {
font-family: inherit; font-family: inherit;
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */ box-sizing: border-box; /* Ensure padding doesn't overflow width */
color: var(--body-text-color); color: #333;
background-color: var(--primary-background-color); background-color: #fff;
} }
.form-control:focus { .form-control:focus {
@@ -712,7 +708,7 @@ body {
select.form-control { select.form-control {
appearance: none; appearance: none;
background-color: var(--primary-background-color); background-color: #fff;
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-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-repeat: no-repeat;
background-position: right 0.75rem center; background-position: right 0.75rem center;
@@ -788,7 +784,7 @@ select.form-control {
.meta-info p:first-child { .meta-info p:first-child {
margin-top: 1.2rem; margin-top: 1.2rem;
padding-top: 1.2rem; padding-top: 1.2rem;
border-top: 1px solid var(--divider-color); border-top: 1px solid #eee;
} }
.meta-info a, .meta-info a,
@@ -847,9 +843,9 @@ abbr[title] {
width: 100%; width: 100%;
text-align: left; text-align: left;
border: none; border: none;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid #eee;
background: var(--primary-background-color); background: #fff;
color: var(--body-text-color); color: #333;
padding: 1rem; padding: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
@@ -1020,7 +1016,7 @@ abbr[title] {
.photo-carousel.inline .photo-carousel-track { .photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none; scroll-snap-type: none;
gap: 2px; gap: 2px;
background-color: var(--primary-background-color); background-color: #fff;
} }
.photo-carousel.inline .carousel-slide { .photo-carousel.inline .carousel-slide {
@@ -1099,7 +1095,7 @@ abbr[title] {
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: var(--body-text-color); color: #333;
border: 1px solid #ccc; border: 1px solid #ccc;
} }
@@ -1108,7 +1104,7 @@ abbr[title] {
} }
.btn-secondary { .btn-secondary {
color: var(--body-text-color); color: #333;
border: 1px solid rgb(255 204 51 / 20%); border: 1px solid rgb(255 204 51 / 20%);
background: rgb(255 204 51 / 30%); background: rgb(255 204 51 / 30%);
} }
@@ -1270,20 +1266,6 @@ span.icon {
gap: 0.5rem; 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 { .content-with-icon .icon {
margin-top: 0.15rem; margin-top: 0.15rem;
} }
@@ -1365,7 +1347,7 @@ span.icon {
width: 24px; width: 24px;
height: 24px; height: 24px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: var(--body-text-color); color: #333;
pointer-events: none; pointer-events: none;
z-index: 2000; z-index: 2000;
display: none; display: none;
@@ -1424,8 +1406,6 @@ button.create-place {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
inset: auto 0 0; inset: auto 0 0;
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
forwards;
} }
.sidebar-content { .sidebar-content {
@@ -1501,7 +1481,7 @@ button.create-place {
min-width: 0; min-width: 0;
height: 100%; height: 100%;
font-size: 1rem; font-size: 1rem;
color: var(--body-text-color); color: #333;
outline: none; outline: none;
width: 100%; width: 100%;
padding: 0 4px; padding: 0 4px;
@@ -1532,7 +1512,7 @@ button.create-place {
.search-submit-btn:hover { .search-submit-btn:hover {
background: rgb(0 0 0 / 5%); background: rgb(0 0 0 / 5%);
color: var(--body-text-color); color: #333;
} }
.search-clear-btn { .search-clear-btn {
@@ -1550,7 +1530,7 @@ button.create-place {
.search-clear-btn:hover { .search-clear-btn:hover {
background: rgb(0 0 0 / 5%); background: rgb(0 0 0 / 5%);
color: var(--body-text-color); color: #333;
} }
/* Search Results Popover */ /* Search Results Popover */
@@ -1619,7 +1599,7 @@ button.create-place {
.result-title { .result-title {
font-weight: 500; font-weight: 500;
color: var(--body-text-color); color: #333;
font-size: 0.95rem; font-size: 0.95rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -1671,7 +1651,7 @@ button.create-place {
cursor: pointer; cursor: pointer;
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--body-text-color); color: #333;
} }
.place-lists-manager input[type='checkbox'] { .place-lists-manager input[type='checkbox'] {
@@ -1681,8 +1661,7 @@ button.create-place {
cursor: pointer; cursor: pointer;
} }
/* Shared List Color Dot */ .place-lists-manager .list-color {
.list-color-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
background-color: var(--default-list-color); background-color: var(--default-list-color);
@@ -1693,7 +1672,7 @@ button.create-place {
.place-lists-manager .divider { .place-lists-manager .divider {
height: 1px; height: 1px;
background: var(--divider-color); background: #eee;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -1736,7 +1715,7 @@ button.create-place {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 16px; /* Pill shape */ border-radius: 16px; /* Pill shape */
font-size: 0.9rem; font-size: 0.9rem;
color: var(--body-text-color); color: #333;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%); box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
@@ -1748,7 +1727,7 @@ button.create-place {
} }
.category-chip:active { .category-chip:active {
background: var(--divider-color); background: #eee;
} }
.category-chip:disabled { .category-chip:disabled {
@@ -1883,7 +1862,7 @@ button.create-place {
.photo-tag-chip { .photo-tag-chip {
background: #f8f9fa; background: #f8f9fa;
color: var(--body-text-color); color: #333;
border: none; border: none;
border-radius: 16px; border-radius: 16px;
padding: 6px 12px; padding: 6px 12px;
@@ -1893,7 +1872,7 @@ button.create-place {
} }
.photo-tag-chip:hover { .photo-tag-chip:hover {
background: var(--divider-color); background: #eee;
} }
.photo-tag-chip.is-selected { .photo-tag-chip.is-selected {
@@ -2124,7 +2103,7 @@ button.create-place {
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--body-text-color); color: #333;
white-space: nowrap; white-space: nowrap;
} }
@@ -2144,117 +2123,3 @@ button.create-place {
align-items: center; align-items: center;
justify-content: 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;
}
/* Sidebar Loading State */
.sidebar-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
}
+24 -23
View File
@@ -2,9 +2,12 @@ import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title'; import { pageTitle } from 'ember-page-title';
import Map from '#components/map'; import Map from '#components/map';
import AppHeader from '#components/app-header'; import AppHeader from '#components/app-header';
import AppMenu from '#components/app-menu/index';
import Toast from '#components/toast'; import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { or } from 'ember-truth-helpers';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component { export default class ApplicationComponent extends Component {
@@ -12,17 +15,16 @@ export default class ApplicationComponent extends Component {
@service mapUi; @service mapUi;
@service router; @service router;
@tracked isAppMenuOpen = false;
get isSidebarOpen() { get isSidebarOpen() {
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible. // 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. // This helps the map know if it should shift the center or adjust view.
const name = this.router.currentRouteName;
return ( return (
this.mapUi.isSidebarVisible && this.mapUi.isSidebarVisible &&
(name === 'place' || (this.router.currentRouteName === 'place' ||
name === 'place.new' || this.router.currentRouteName === 'place.new' ||
name === 'search' || this.router.currentRouteName === 'search')
name === 'menu' ||
name.startsWith('lists'))
); );
} }
@@ -35,27 +37,24 @@ export default class ApplicationComponent extends Component {
@action @action
toggleAppMenu() { toggleAppMenu() {
if (this.router.currentRouteName === 'menu') { this.isAppMenuOpen = !this.isAppMenuOpen;
this.router.transitionTo('index');
} else {
this.router.transitionTo('menu');
} }
@action
closeAppMenu() {
this.isAppMenuOpen = false;
} }
@action @action
handleOutsideClick() { handleOutsideClick() {
const name = this.router.currentRouteName; if (this.isAppMenuOpen) {
if ( this.closeAppMenu();
name === 'search' || } else if (
name === 'place' || this.router.currentRouteName === 'search' ||
name === 'menu' || this.router.currentRouteName === 'place'
name.startsWith('lists')
) { ) {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) {
this.router.transitionTo('index');
}
} }
} }
@@ -67,7 +66,6 @@ export default class ApplicationComponent extends Component {
<template> <template>
{{pageTitle "Marco"}} {{pageTitle "Marco"}}
<div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
<AppHeader @onToggleMenu={{this.toggleAppMenu}} /> <AppHeader @onToggleMenu={{this.toggleAppMenu}} />
<div <div
@@ -84,13 +82,16 @@ export default class ApplicationComponent extends Component {
{{/if}} {{/if}}
<Map <Map
@isSidebarOpen={{this.isSidebarOpen}} @isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
@onOutsideClick={{this.handleOutsideClick}} @onOutsideClick={{this.handleOutsideClick}}
/> />
{{#if this.isAppMenuOpen}}
<AppMenu @onClose={{this.closeAppMenu}} />
{{/if}}
<Toast /> <Toast />
{{outlet}} {{outlet}}
</div>
</template> </template>
} }
-1
View File
@@ -1 +0,0 @@
<template>{{outlet}}</template>
-89
View File
@@ -1,89 +0,0 @@
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>
}
-16
View File
@@ -1,16 +0,0 @@
import PlacesSidebar from '#components/places-sidebar';
<template>
{{#if @controller.mapUi.isSidebarVisible}}
<PlacesSidebar
@places={{@controller.places}}
@title={{@controller.listTitle}}
@color={{@controller.listColor}}
@scrollTop={{@controller.scrollTop}}
@isLoading={{@controller.loadPlacesTask.isRunning}}
@onSelect={{@controller.selectPlace}}
@onClose={{@controller.close}}
@onBack={{@controller.backToLists}}
/>
{{/if}}
</template>
-15
View File
@@ -1,15 +0,0 @@
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>
}
+1 -7
View File
@@ -77,14 +77,8 @@ export default class PlaceTemplate extends Component {
navigateBack(place) { navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked. // The sidebar calls this with null when "Back" is clicked.
if (place === null) { 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 we have an active search context, return to it (UP navigation)
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.mapUi.showSidebar(); this.mapUi.showSidebar();
this.router.transitionTo('search', { this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch, queryParams: this.mapUi.currentSearch,
-5
View File
@@ -10,10 +10,6 @@ export default class SearchTemplate extends Component {
@action @action
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
}
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
this.mapUi.showSidebar(); this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true; this.mapUi.preventNextZoom = true;
@@ -32,7 +28,6 @@ export default class SearchTemplate extends Component {
{{#if this.mapUi.isSidebarVisible}} {{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@places={{this.mapUi.searchResults}} @places={{this.mapUi.searchResults}}
@scrollTop={{this.mapUi.getScrollPosition "search"}}
@onSelect={{this.selectPlace}} @onSelect={{this.selectPlace}}
@onClose={{this.close}} @onClose={{this.close}}
/> />
-27
View File
@@ -1,7 +1,4 @@
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local // 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 activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw'; import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw'; import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
@@ -43,9 +40,6 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw'; import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
import zap from 'feather-icons/dist/icons/zap.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 angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw'; import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw'; import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
@@ -59,12 +53,8 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.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 boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.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 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 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 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 classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw'; import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
@@ -92,13 +82,8 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.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 iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.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 jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.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 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 openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw'; import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -133,9 +118,6 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw'; 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'; 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 loadingRing from '../icons/270-ring.svg?raw';
import nostrich from '../icons/nostrich-2.svg?raw'; import nostrich from '../icons/nostrich-2.svg?raw';
import remotestorage from '../icons/remotestorage.svg?raw'; import remotestorage from '../icons/remotestorage.svg?raw';
@@ -162,13 +144,11 @@ const ICONS = {
'chevron-left': chevronLeft, 'chevron-left': chevronLeft,
'chevron-right': chevronRight, 'chevron-right': chevronRight,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl, 'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
'city-gate': cityGate,
climbing_wall: climbingWall, climbing_wall: climbingWall,
check, check,
'alert-circle': alertCircle, 'alert-circle': alertCircle,
'alert-triangle': alertTriangle, 'alert-triangle': alertTriangle,
'classical-building': classicalBuilding, 'classical-building': classicalBuilding,
'classical-building-with-clock': classicalBuildingWithClock,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag, 'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag, 'classical-building-with-flag': classicalBuildingWithFlag,
'commercial-building': commercialBuilding, 'commercial-building': commercialBuilding,
@@ -205,7 +185,6 @@ const ICONS = {
'ice-cream-on-cone': iceCreamOnCone, 'ice-cream-on-cone': iceCreamOnCone,
'industrial-building': industrialBuilding, 'industrial-building': industrialBuilding,
info, info,
'info-i': infoI,
instagram, instagram,
jewel, jewel,
'log-in': logIn, 'log-in': logIn,
@@ -214,11 +193,7 @@ const ICONS = {
mail, mail,
map, map,
'map-pin': mapPin, 'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu, menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
'more-horizontal': moreHorizontal, 'more-horizontal': moreHorizontal,
'more-vertical': moreVertical, 'more-vertical': moreVertical,
navigation, navigation,
@@ -270,8 +245,6 @@ const ICONS = {
winding_way_wide: windingWayWide, winding_way_wide: windingWayWide,
parking_p: parkingP, parking_p: parkingP,
car, car,
'car-and-wrench': carAndWrench,
'castle-keep': castleKeep,
x, x,
zap, zap,
'loading-ring': loadingRing, 'loading-ring': loadingRing,
-24
View File
@@ -14,30 +14,6 @@ export function normalizeRelayUrl(url) {
return normalized; 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. * 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. * Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
-6
View File
@@ -27,8 +27,6 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'bank' }, icon: 'banknote' }, { tags: { amenity: 'bank' }, icon: 'banknote' },
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' }, { 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: 'fire_station' }, icon: 'badge-shield-with-fire' },
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' }, { tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' }, { tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
@@ -74,7 +72,6 @@ export const POI_ICON_RULES = [
tags: { shop: 'beauty' }, tags: { shop: 'beauty' },
icon: 'fancy-mirror-with-reflection-and-stars', 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: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' }, { tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { office: true }, icon: 'commercial-building' }, { tags: { office: true }, icon: 'commercial-building' },
@@ -106,7 +103,6 @@ export const POI_ICON_RULES = [
{ tags: { tourism: 'viewpoint' }, icon: 'camera' }, { tags: { tourism: 'viewpoint' }, icon: 'camera' },
{ tags: { tourism: 'zoo' }, icon: 'camera' }, { tags: { tourism: 'zoo' }, icon: 'camera' },
{ tags: { tourism: 'artwork' }, icon: 'camera' }, { tags: { tourism: 'artwork' }, icon: 'camera' },
{ tags: { tourism: 'information' }, icon: 'info-i' },
{ tags: { amenity: 'cinema' }, icon: 'film' }, { tags: { amenity: 'cinema' }, icon: 'film' },
{ tags: { amenity: 'theatre' }, icon: 'camera' }, { tags: { amenity: 'theatre' }, icon: 'camera' },
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' }, { tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
@@ -117,9 +113,7 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'bridge' }, icon: 'bridge' }, { tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' }, { tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' }, { tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
{ tags: { historic: 'castle' }, icon: 'palace' }, { tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' }, { tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' }, { tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' }, { tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
+1 -10
View File
@@ -56,24 +56,15 @@ const PLACE_TYPE_KEYS = [
export function getPlaceType(tags) { export function getPlaceType(tags) {
if (!tags) return null; if (!tags) return null;
let fallbackKey = null;
for (const key of PLACE_TYPE_KEYS) { for (const key of PLACE_TYPE_KEYS) {
const value = tags[key]; const value = tags[key];
if (value) { if (value) {
if (value === 'yes') { if (value === 'yes') {
if (!fallbackKey) { return humanizeOsmTag(key);
fallbackKey = key;
}
continue;
} }
return humanizeOsmTag(value); return humanizeOsmTag(value);
} }
} }
if (fallbackKey) {
return humanizeOsmTag(fallbackKey);
}
return null; return null;
} }
+2 -2
View File
@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
label: 'Things to do', label: 'Things to do',
icon: 'feather-camera', icon: 'feather-camera',
filter: [ filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]', '["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]', '["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
'["leisure"~"^(sports_centre|stadium|water_park)$"]', '["leisure"~"^(sports_centre|stadium|water_park)$"]',
'["historic"]', '["historic"]',
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation', id: 'accommodation',
label: 'Hotels', label: 'Hotels',
icon: 'person-sleeping-in-bed', icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'], filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
types: ['node', 'way', 'relation'], types: ['node', 'way', 'relation'],
}, },
]; ];
+1 -1
View File
@@ -52,7 +52,7 @@
"@embroider/vite": "^1.5.0", "@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0", "@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "~1.3.0", "@remotestorage/module-places": "~1.2.1",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0", "@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0", "@warp-drive/ember": "~5.8.0",
+5 -5
View File
@@ -88,8 +88,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@remotestorage/module-places': '@remotestorage/module-places':
specifier: ~1.3.0 specifier: ~1.2.1
version: 1.3.0 version: 1.2.1
'@rollup/plugin-babel': '@rollup/plugin-babel':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1) version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
@@ -1468,8 +1468,8 @@ packages:
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==} resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
'@remotestorage/module-places@1.3.0': '@remotestorage/module-places@1.2.1':
resolution: {integrity: sha512-VM0CqkIP6IBEpjqJ2DyTrGDOQXc73aXoAFDLIoME5Lo033uTitgn+qKgTGPK/lD4H92mk1+D3W88ECtf9v3mWw==} resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
'@rollup/plugin-babel@6.1.0': '@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
@@ -7439,7 +7439,7 @@ snapshots:
'@pnpm/error': 1000.0.5 '@pnpm/error': 1000.0.5
find-up: 5.0.0 find-up: 5.0.0
'@remotestorage/module-places@1.3.0': '@remotestorage/module-places@1.2.1':
dependencies: dependencies:
latlon-geohash: 2.0.0 latlon-geohash: 2.0.0
ulid: 3.0.2 ulid: 3.0.2
-138
View File
@@ -1,138 +0,0 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
class MockOsmService extends Service {
async fetchOsmObject() {
return null;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [
{
id: 'place-123',
title: 'Mountain Trail',
geohash: 'u33dc0',
osmTags: { name: 'Mountain Trail' },
},
];
lists = [
{
id: 'to-go',
title: 'Want to go',
color: '#2e9e4f',
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
},
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
];
findPlaceById(id) {
if (id === 'place-123') {
return this.savedPlaces[0];
}
return null;
}
isPlaceSaved() {
return true;
}
loadPlacesInBounds() {
return [];
}
getPlacesInList(listId) {
if (listId === 'to-go') {
return Promise.resolve([this.savedPlaces[0]]);
}
return Promise.resolve([]);
}
rs = {
on: () => {},
};
}
module('Acceptance | collections navigation', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osm', MockOsmService);
this.owner.register('service:storage', MockStorageService);
});
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
// 1. Visit Home Map
await visit('/');
assert.strictEqual(currentURL(), '/');
// 2. Open the App Menu overlay
await click('.menu-btn-integrated');
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
assert
.dom('.app-menu')
.includesText('Collections', 'Menu contains Collections link');
// 3. Transition to Collections Index (List of lists)
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
assert
.dom('.sidebar-header-text-centered')
.includesText('Collections', 'Header is centered and titled Collections');
assert
.dom('.lists-index-item')
.exists({ count: 2 }, 'Renders our 2 mocked list items');
// 4. Transition to a specific list (Want to go)
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Transitions instantly to /lists/to-go'
);
// 5. Verify background loading spinner shows up, then results populate
await waitFor('.places-list');
assert
.dom('.places-list .place-name')
.hasText('Mountain Trail', 'Renders the saved place from the list');
assert
.dom('.places-list .place-type')
.hasText('Saved place', 'Place type displays Saved place correctly');
// 6. Click back button in collection list header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
// 7. Click back button in collections index header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
// 8. Close sidebar
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
});
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
await visit('/lists/to-go');
await waitFor('.places-list');
// Click on the place item to view details
await click('.place-item');
assert.ok(
currentURL().includes('/place/place-123'),
'Transitions to place details route'
);
// Click back from place details
await click('.back-btn');
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Returns gracefully back to lists/to-go list view'
);
});
});
-87
View File
@@ -4,92 +4,6 @@ import {
setupTest as upstreamSetupTest, setupTest as upstreamSetupTest,
} from 'ember-qunit'; } from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr'; import { setupNostrMocks } from './mock-nostr';
import sinon from 'sinon';
function setupMapStyleMocks(hooks) {
hooks.beforeEach(function () {
// Stub window.fetch to capture map-style assets before they hit the network
this.fetchStub = sinon.stub(window, 'fetch');
this.fetchStub.callsFake(async (input, init) => {
let url = input;
if (typeof input === 'object' && input !== null && 'url' in input) {
url = input.url;
}
if (typeof url === 'string' && url.includes('tiles.openfreemap.org')) {
// A. Mock Style Sheet
if (url.includes('/styles/liberty')) {
return {
ok: true,
status: 200,
json: async () => ({
version: 8,
name: 'Mock Style',
sources: {
openmaptiles: {
type: 'vector',
tiles: [], // Empty tiles list prevents any map tile fetching completely!
},
},
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
glyphs:
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f9fa' },
},
],
}),
};
}
// B. Mock Sprite Atlas JSON
if (url.endsWith('.json') && url.includes('/sprites/')) {
return {
ok: true,
status: 200,
json: async () => ({}), // Empty sprite dictionary
};
}
// C. Mock Sprite Atlas PNG (Returns a valid 1x1 transparent PNG)
if (url.endsWith('.png') && url.includes('/sprites/')) {
const bytes = new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73,
68, 65, 84, 120, 156, 99, 96, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0,
0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
]);
const blob = new Blob([bytes], { type: 'image/png' });
return {
ok: true,
status: 200,
blob: async () => blob,
};
}
// Catch-all mock for other openfreemap endpoints
return {
ok: true,
status: 200,
json: async () => ({}),
};
}
// Pass through to original fetch (e.g. Photon results, local mock APIs)
return this.fetchStub.wrappedMethod(input, init);
});
});
hooks.afterEach(function () {
if (this.fetchStub && typeof this.fetchStub.restore === 'function') {
this.fetchStub.restore();
}
});
}
// This file exists to provide wrappers around ember-qunit's // This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is // test setup functions. This way, you can easily extend the setup that is
@@ -98,7 +12,6 @@ function setupMapStyleMocks(hooks) {
function setupApplicationTest(hooks, options) { function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options); upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks); setupNostrMocks(hooks);
setupMapStyleMocks(hooks);
// Additional setup for application tests can be done here. // Additional setup for application tests can be done here.
// //
+2 -10
View File
@@ -49,19 +49,11 @@ export class MockNostrDataService extends Service {
return []; return [];
} }
get requiredReadRelays() { get defaultReadRelays() {
return []; return [];
} }
get requiredWriteRelays() { get defaultWriteRelays() {
return [];
}
get mailboxReadRelays() {
return [];
}
get mailboxWriteRelays() {
return []; return [];
} }
@@ -1,188 +0,0 @@
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',
]);
});
});
@@ -334,54 +334,4 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom(links[0]).hasText('+44 987 654 321'); assert.dom(links[0]).hasText('+44 987 654 321');
assert.dom(links[1]).hasText('+1 234-567 8900'); 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();
});
}); });
+1 -62
View File
@@ -1,11 +1,5 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
parsePlacePhotos,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
module('Unit | Utility | nostr', function () { module('Unit | Utility | nostr', function () {
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) { test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
@@ -147,59 +141,4 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123'); assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456'); 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',
]);
});
}); });
+1 -12
View File
@@ -1,5 +1,4 @@
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons'; import { getIconNameForTags } from 'marco/utils/osm-icons';
import { getIcon } from 'marco/utils/icons';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
module('Unit | Utility | osm-icons', function () { module('Unit | Utility | osm-icons', function () {
@@ -37,14 +36,4 @@ module('Unit | Utility | osm-icons', function () {
let result = getIconNameForTags({ foo: 'bar' }); let result = getIconNameForTags({ foo: 'bar' });
assert.strictEqual(result, null); 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`
);
}
});
}); });
-6
View File
@@ -89,12 +89,6 @@ module('Unit | Utility | osm', function (hooks) {
assert.strictEqual(result, 'Building'); 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) { test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
// If something is both a shop and a building, it should be a shop // If something is both a shop and a building, it should be a shop
const tags = { building: 'yes', shop: 'supermarket' }; const tags = { building: 'yes', shop: 'supermarket' };