Compare commits
24 Commits
v1.23.0
...
feature/li
| Author | SHA1 | Date | |
|---|---|---|---|
|
96a5a6ac34
|
|||
|
78996b6c61
|
|||
|
bb5b69711c
|
|||
|
ad9c489102
|
|||
|
c11882adfb
|
|||
|
707f4ac11c
|
|||
|
3bada05b63
|
|||
|
f01730fef5
|
|||
|
448c51bab6
|
|||
|
0bcbae374b
|
|||
|
c33fe3b268
|
|||
|
18bda60310
|
|||
|
86d25dc6ba
|
|||
|
5b8bec6a00
|
|||
|
f2c2eb1fdc
|
|||
|
b42c4881f6
|
|||
|
b18e299eca
|
|||
|
401ed41fcd
|
|||
|
504e8fab94
|
|||
|
76897c9e69
|
|||
|
59bc5ca046
|
|||
|
ef4bb8f51a
|
|||
|
f82a797720
|
|||
|
f9cb22ee0e
|
@@ -3,11 +3,11 @@ import Icon from '#components/icon';
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>About</h2>
|
||||
<h2 class="sidebar-header-text-centered">About</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="app-menu">
|
||||
<li>
|
||||
<button type="button" {{on "click" @onSavedPlaces}}>
|
||||
<Icon @name="bookmark" @size={{20}} />
|
||||
<span>Collections</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
import AppMenuHome from './home';
|
||||
@@ -9,6 +10,7 @@ import AppMenuSettings from './settings';
|
||||
import AppMenuAbout from './about';
|
||||
|
||||
export default class AppMenu extends Component {
|
||||
@service router;
|
||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||
|
||||
@action
|
||||
@@ -16,10 +18,19 @@ export default class AppMenu extends Component {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
@action
|
||||
goToSavedPlaces() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||
<AppMenuHome
|
||||
@onNavigate={{this.setView}}
|
||||
@onClose={{@onClose}}
|
||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
<AppMenuSettings
|
||||
|
||||
@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<h2 class="sidebar-header-text-centered">Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,11 @@ import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
import { normalizeRelayUrl } from '../../../utils/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
} from '../../../utils/nostr';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
@@ -17,6 +21,74 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
@tracked newReadRelay = '';
|
||||
@tracked newWriteRelay = '';
|
||||
|
||||
get customReadRelays() {
|
||||
return excludeRequiredRelays(
|
||||
this.settings.nostrReadRelays || [],
|
||||
this.nostrData.requiredReadRelays
|
||||
);
|
||||
}
|
||||
|
||||
get customWriteRelays() {
|
||||
return excludeRequiredRelays(
|
||||
this.settings.nostrWriteRelays || [],
|
||||
this.nostrData.requiredWriteRelays
|
||||
);
|
||||
}
|
||||
|
||||
get readRelayExclusions() {
|
||||
return this.settings.nostrReadRelayExclusions || [];
|
||||
}
|
||||
|
||||
get writeRelayExclusions() {
|
||||
return this.settings.nostrWriteRelayExclusions || [];
|
||||
}
|
||||
|
||||
get requiredReadRelaySet() {
|
||||
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get requiredWriteRelaySet() {
|
||||
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get mailboxReadRelaySet() {
|
||||
return new Set(this.nostrData.mailboxReadRelays);
|
||||
}
|
||||
|
||||
get mailboxWriteRelaySet() {
|
||||
return new Set(this.nostrData.mailboxWriteRelays);
|
||||
}
|
||||
|
||||
get hasReadOverrides() {
|
||||
return (
|
||||
this.customReadRelays.length > 0 || this.readRelayExclusions.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
get hasWriteOverrides() {
|
||||
return (
|
||||
this.customWriteRelays.length > 0 || this.writeRelayExclusions.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
get readRelaysForDisplay() {
|
||||
return this.nostrData.activeReadRelays.map((url) => {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredReadRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get writeRelaysForDisplay() {
|
||||
return this.nostrData.activeWriteRelays.map((url) => {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredWriteRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateNewReadRelay(event) {
|
||||
this.newReadRelay = event.target.value;
|
||||
@@ -32,19 +104,51 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
const url = normalizeRelayUrl(this.newReadRelay);
|
||||
if (!url) return;
|
||||
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrReadRelays', Array.from(set));
|
||||
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [
|
||||
...this.customReadRelays,
|
||||
url,
|
||||
]);
|
||||
const custom = excludeRequiredRelays(
|
||||
merged,
|
||||
this.nostrData.requiredReadRelays
|
||||
);
|
||||
|
||||
const readExclusions = this.readRelayExclusions.filter((relay) => {
|
||||
return normalizeRelayUrl(relay) !== url;
|
||||
});
|
||||
|
||||
this.settings.update('nostrReadRelays', custom.length > 0 ? custom : null);
|
||||
this.settings.update(
|
||||
'nostrReadRelayExclusions',
|
||||
readExclusions.length > 0 ? readExclusions : null
|
||||
);
|
||||
this.newReadRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeReadRelay(url) {
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrReadRelays', filtered);
|
||||
if (this.requiredReadRelaySet.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
|
||||
const remainingCustom = this.customReadRelays.filter((relay) => {
|
||||
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||
});
|
||||
|
||||
const nextExclusions = this.mailboxReadRelaySet.has(normalizedUrl)
|
||||
? Array.from(new Set([...this.readRelayExclusions, normalizedUrl]))
|
||||
: this.readRelayExclusions;
|
||||
|
||||
this.settings.update(
|
||||
'nostrReadRelays',
|
||||
remainingCustom.length > 0 ? remainingCustom : null
|
||||
);
|
||||
this.settings.update(
|
||||
'nostrReadRelayExclusions',
|
||||
nextExclusions.length > 0 ? nextExclusions : null
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -64,6 +168,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
@action
|
||||
resetReadRelays() {
|
||||
this.settings.update('nostrReadRelays', null);
|
||||
this.settings.update('nostrReadRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -71,24 +176,57 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
if (!url) return;
|
||||
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrWriteRelays', Array.from(set));
|
||||
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [
|
||||
...this.customWriteRelays,
|
||||
url,
|
||||
]);
|
||||
const custom = excludeRequiredRelays(
|
||||
merged,
|
||||
this.nostrData.requiredWriteRelays
|
||||
);
|
||||
|
||||
const writeExclusions = this.writeRelayExclusions.filter((relay) => {
|
||||
return normalizeRelayUrl(relay) !== url;
|
||||
});
|
||||
|
||||
this.settings.update('nostrWriteRelays', custom.length > 0 ? custom : null);
|
||||
this.settings.update(
|
||||
'nostrWriteRelayExclusions',
|
||||
writeExclusions.length > 0 ? writeExclusions : null
|
||||
);
|
||||
this.newWriteRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeWriteRelay(url) {
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrWriteRelays', filtered);
|
||||
if (this.requiredWriteRelaySet.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(url);
|
||||
|
||||
const remainingCustom = this.customWriteRelays.filter((relay) => {
|
||||
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||
});
|
||||
|
||||
const nextExclusions = this.mailboxWriteRelaySet.has(normalizedUrl)
|
||||
? Array.from(new Set([...this.writeRelayExclusions, normalizedUrl]))
|
||||
: this.writeRelayExclusions;
|
||||
|
||||
this.settings.update(
|
||||
'nostrWriteRelays',
|
||||
remainingCustom.length > 0 ? remainingCustom : null
|
||||
);
|
||||
this.settings.update(
|
||||
'nostrWriteRelayExclusions',
|
||||
nextExclusions.length > 0 ? nextExclusions : null
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
resetWriteRelays() {
|
||||
this.settings.update('nostrWriteRelays', null);
|
||||
this.settings.update('nostrWriteRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -112,18 +250,20 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeReadRelays as |relay|}}
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeReadRelay relay)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -143,7 +283,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrReadRelays}}
|
||||
{{#if this.hasReadOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
@@ -157,18 +297,20 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeWriteRelays as |relay|}}
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeWriteRelay relay)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -188,7 +330,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrWriteRelays}}
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
|
||||
@@ -23,6 +23,7 @@ export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
@@ -345,9 +346,21 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
get osmUrl() {
|
||||
const id = this.place.osmId;
|
||||
if (!id) return null;
|
||||
const type = this.place.osmType || 'node';
|
||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||
if (id) {
|
||||
const type = this.place.osmType || 'node';
|
||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||
}
|
||||
|
||||
const lat = this.place.lat;
|
||||
const lon = this.place.lon;
|
||||
if (!lat || !lon) return null;
|
||||
|
||||
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
|
||||
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
|
||||
const zoom = this.mapUi.currentZoom ?? 17;
|
||||
const roundedZoom = Math.round(zoom);
|
||||
|
||||
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
|
||||
}
|
||||
|
||||
get gmapsUrl() {
|
||||
@@ -591,7 +604,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
</div>
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
{{#if this.place.osmId}}
|
||||
<div class="meta-info">
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="feather-camera" />
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
|
||||
checked={{this.isSaved}}
|
||||
{{on "change" this.toggleSaved}}
|
||||
/>
|
||||
<span class="list-color"></span>
|
||||
<span class="list-color-dot"></span>
|
||||
<span class="list-name">Saved places</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
|
||||
/>
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<span
|
||||
class="list-color"
|
||||
class="list-color-dot"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<span class="list-name">{{list.title}}</span>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import and from 'ember-truth-helpers/helpers/and';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -160,7 +163,11 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||
<div
|
||||
class="sidebar-header
|
||||
{{if this.hasHeaderPhoto 'no-border'}}
|
||||
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
|
||||
>
|
||||
{{#if @selectedPlace}}
|
||||
<button
|
||||
type="button"
|
||||
@@ -168,11 +175,32 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{#if @onBack}}
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
|
||||
@name="arrow-left"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
{{/if}}
|
||||
{{#if @onBack}}
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@size={{20}}
|
||||
@color={{or @color "#898989"}}
|
||||
/>
|
||||
</span>
|
||||
{{@title}}
|
||||
</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" />
|
||||
Results</h2>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@@ -182,7 +210,7 @@ export default class PlacesSidebar extends Component {
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
{{#if @selectedPlace}}
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@@ -210,12 +238,10 @@ export default class PlacesSidebar extends Component {
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else if (getPlaceType place.osmTags)}}
|
||||
{{getPlaceType place.osmTags}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
Saved place
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
|
||||
export default class SearchBoxComponent extends Component {
|
||||
@service photon;
|
||||
@service osm;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
|
||||
this.searchTask.perform(value);
|
||||
}
|
||||
|
||||
formatSavedPlace(place) {
|
||||
const listNames = (place._listIds || [])
|
||||
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const description = listNames
|
||||
? `Saved place (${listNames})`
|
||||
: 'Saved place';
|
||||
|
||||
return {
|
||||
source: 'saved',
|
||||
id: place.id,
|
||||
title: place.title,
|
||||
icon: 'bookmark',
|
||||
description,
|
||||
osmId: place.osmId,
|
||||
osmType: place.osmType,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
};
|
||||
}
|
||||
|
||||
searchTask = task({ restartable: true }, async (term) => {
|
||||
await timeout(300);
|
||||
|
||||
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
|
||||
icon: 'search',
|
||||
}));
|
||||
|
||||
// Filter saved places (minimum 3 characters)
|
||||
let savedMatches = [];
|
||||
if (q.length >= 3) {
|
||||
savedMatches = this.storage.savedPlaces
|
||||
.filter((p) => p.title && p.title.toLowerCase().includes(q))
|
||||
.map((p) => this.formatSavedPlace(p));
|
||||
}
|
||||
|
||||
const results = await this.photon.search(query, lat, lon);
|
||||
this.results = [...categoryMatches, ...results];
|
||||
|
||||
// Deduplicate Photon results that are already in saved matches
|
||||
const savedOsmIds = new Set(
|
||||
savedMatches.map((s) => s.osmId).filter(Boolean)
|
||||
);
|
||||
const filteredPhotonResults = results.filter(
|
||||
(r) => !savedOsmIds.has(r.osmId)
|
||||
);
|
||||
|
||||
this.results = [
|
||||
...categoryMatches,
|
||||
...savedMatches,
|
||||
...filteredPhotonResults,
|
||||
];
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
this.results = [];
|
||||
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
|
||||
}
|
||||
this.results = []; // Hide popover
|
||||
|
||||
// If it has an OSM ID, go to place details
|
||||
if (place.osmId) {
|
||||
// If it's a custom saved place without an OSM ID, go to place details via internal ID
|
||||
if (place.source === 'saved' && place.id && !place.osmId) {
|
||||
this.router.transitionTo('place', place.id);
|
||||
}
|
||||
// If it has an OSM ID, go to place details via OSM ID
|
||||
else if (place.osmId) {
|
||||
// Format: osm:node:123
|
||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||
|
||||
@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name
|
||||
// Search local bookmarks by name (minimum 3 characters)
|
||||
const queryLower = params.q.toLowerCase();
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
let localMatches = [];
|
||||
if (queryLower.length >= 3) {
|
||||
localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
|
||||
10
app/modifiers/restore-scroll.js
Normal file
10
app/modifiers/restore-scroll.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [scrollTop]) => {
|
||||
if (element && typeof scrollTop === 'number' && scrollTop > 0) {
|
||||
// Restore inside requestAnimationFrame to guarantee layout rendering is ready
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,10 @@ Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
this.route('menu');
|
||||
this.route('lists', function () {
|
||||
this.route('list', { path: '/:list_id' });
|
||||
});
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
|
||||
@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.clearSearchResults();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
10
app/routes/lists.js
Normal file
10
app/routes/lists.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
}
|
||||
3
app/routes/lists/index.js
Normal file
3
app/routes/lists/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ListsIndexRoute extends Route {}
|
||||
17
app/routes/lists/list.js
Normal file
17
app/routes/lists/list.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
async model(params) {
|
||||
const listId = params.list_id;
|
||||
try {
|
||||
const places = await this.storage.getPlacesInList(listId);
|
||||
return { listId, places };
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
return { listId, places: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/routes/menu.js
Normal file
10
app/routes/menu.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class MenuRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export default class PlaceRoute extends Route {
|
||||
this.mapUi.clearSelection();
|
||||
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
||||
this.mapUi.returnToSearch = false;
|
||||
this.mapUi.returnToRoute = null;
|
||||
}
|
||||
|
||||
async loadOsmPlace(id, type = null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@service nostrData;
|
||||
@@ -9,6 +10,7 @@ export default class MapUiService extends Service {
|
||||
@tracked isCreating = false;
|
||||
@tracked creationCoordinates = null;
|
||||
@tracked returnToSearch = false;
|
||||
@tracked returnToRoute = null;
|
||||
@tracked currentCenter = null;
|
||||
@tracked currentBounds = null;
|
||||
@tracked currentZoom = null;
|
||||
@@ -19,13 +21,33 @@ export default class MapUiService extends Service {
|
||||
@tracked currentSearch = null;
|
||||
@tracked loadingState = null;
|
||||
@tracked isSidebarVisible = false;
|
||||
@tracked isSidebarOpening = false;
|
||||
|
||||
scrollPositions = {};
|
||||
|
||||
@action
|
||||
saveScrollPosition(key, value) {
|
||||
this.scrollPositions[key] = value;
|
||||
}
|
||||
|
||||
@action
|
||||
getScrollPosition(key) {
|
||||
return this.scrollPositions[key] || 0;
|
||||
}
|
||||
|
||||
showSidebar() {
|
||||
this.isSidebarVisible = true;
|
||||
if (!this.isSidebarVisible) {
|
||||
this.isSidebarVisible = true;
|
||||
this.isSidebarOpening = true;
|
||||
setTimeout(() => {
|
||||
this.isSidebarOpening = false;
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
this.isSidebarVisible = false;
|
||||
this.isSidebarOpening = false;
|
||||
}
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
|
||||
@@ -6,7 +6,12 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import { normalizeRelayUrl } from '../utils/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
uniqNormalizedRelays,
|
||||
} from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
|
||||
const DIRECTORY_RELAYS = [
|
||||
@@ -83,43 +88,62 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
get defaultReadRelays() {
|
||||
const mailboxes = (this.mailboxes?.inboxes || [])
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean);
|
||||
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||
get requiredReadRelays() {
|
||||
return DEFAULT_READ_RELAYS;
|
||||
}
|
||||
|
||||
get defaultWriteRelays() {
|
||||
const mailboxes = (this.mailboxes?.outboxes || [])
|
||||
get requiredWriteRelays() {
|
||||
return DEFAULT_WRITE_RELAYS;
|
||||
}
|
||||
|
||||
get mailboxReadRelays() {
|
||||
return (this.mailboxes?.inboxes || [])
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean);
|
||||
const defaults =
|
||||
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||
}
|
||||
|
||||
get mailboxWriteRelays() {
|
||||
return (this.mailboxes?.outboxes || [])
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
get configuredReadRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxReadRelays,
|
||||
...(this.settings.nostrReadRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrReadRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get configuredWriteRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxWriteRelays,
|
||||
...(this.settings.nostrWriteRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrWriteRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
if (this.settings.nostrReadRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultReadRelays;
|
||||
return mergeRequiredRelays(
|
||||
this.requiredReadRelays,
|
||||
this.configuredReadRelays
|
||||
);
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
if (this.settings.nostrWriteRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultWriteRelays;
|
||||
return mergeRequiredRelays(
|
||||
this.requiredWriteRelays,
|
||||
this.configuredWriteRelays
|
||||
);
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
|
||||
@@ -9,6 +9,8 @@ const DEFAULT_SETTINGS = {
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrReadRelays: null,
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
@@ -21,6 +23,9 @@ export default class SettingsService extends Service {
|
||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||
@tracked nostrWriteRelayExclusions =
|
||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||
@tracked experimentalEnablePhotoDeletion =
|
||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -111,6 +116,8 @@ export default class SettingsService extends Service {
|
||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||
this.experimentalEnablePhotoDeletion =
|
||||
finalSettings.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -127,6 +134,8 @@ export default class SettingsService extends Service {
|
||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||
nostrReadRelays: this.nostrReadRelays,
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
@@ -270,6 +270,11 @@ export default class StorageService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
async getPlacesInList(listId) {
|
||||
if (!this.places || !this.places.lists) return [];
|
||||
return this.places.lists.getPlaces(listId);
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
// 1. Calculate required prefixes
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||
|
||||
:root {
|
||||
--default-list-color: #fc3;
|
||||
--body-text-color: #333;
|
||||
--primary-background-color: #fff;
|
||||
--hover-bg: #f8f9fa;
|
||||
--divider-color: #eee;
|
||||
--sidebar-width: 350px;
|
||||
--link-color: #2a7fff;
|
||||
--link-color-visited: #6a4fbf;
|
||||
@@ -10,6 +12,7 @@
|
||||
--marker-color-dark: #b31412;
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -31,7 +34,7 @@ body {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -112,8 +115,6 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-area: search;
|
||||
|
||||
/* Ensure it sits at the start of its grid area */
|
||||
justify-self: start;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -127,7 +128,6 @@ body {
|
||||
|
||||
@media (width > 768px) {
|
||||
.header-left {
|
||||
/* Desktop: Ensure minimum width for search box so it's not squeezed */
|
||||
min-width: 300px;
|
||||
max-width: 350px;
|
||||
}
|
||||
@@ -140,8 +140,6 @@ body {
|
||||
|
||||
.header-center {
|
||||
grid-area: chips;
|
||||
|
||||
/* Desktop: Center the chips block in the available space */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
@@ -156,7 +154,6 @@ body {
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
/* No need to reset min-width/max-width since they are only set in media query above */
|
||||
.header-center {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -383,7 +380,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.account-item:last-child {
|
||||
@@ -460,6 +457,11 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Ensure flex children are contained */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sidebar-opening .sidebar {
|
||||
animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.sidebar.app-menu-pane {
|
||||
@@ -478,11 +480,13 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
height: 56px; /* Strictly enforce identical vertical height */
|
||||
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box; /* Guarantee strict height boundaries */
|
||||
}
|
||||
|
||||
.sidebar-header.no-border {
|
||||
@@ -491,7 +495,7 @@ body {
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -526,7 +530,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
@@ -557,7 +561,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
@@ -628,7 +632,7 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
@@ -644,7 +648,7 @@ body {
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
background-color: var(--danger-color);
|
||||
color: #fff;
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
@@ -674,7 +678,7 @@ body {
|
||||
margin-bottom: 1rem;
|
||||
background: var(--hover-bg);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -696,8 +700,8 @@ body {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
color: var(--body-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
@@ -708,7 +712,7 @@ body {
|
||||
|
||||
select.form-control {
|
||||
appearance: none;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
@@ -784,7 +788,7 @@ select.form-control {
|
||||
.meta-info p:first-child {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.meta-info a,
|
||||
@@ -843,9 +847,9 @@ abbr[title] {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
@@ -1016,7 +1020,7 @@ abbr[title] {
|
||||
.photo-carousel.inline .photo-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide {
|
||||
@@ -1095,7 +1099,7 @@ abbr[title] {
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1104,7 +1108,7 @@ abbr[title] {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid rgb(255 204 51 / 20%);
|
||||
background: rgb(255 204 51 / 30%);
|
||||
}
|
||||
@@ -1266,6 +1270,20 @@ span.icon {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-with-icon > span:not(.icon) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content-with-icon > span:not(.icon) a {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.content-with-icon .icon {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
@@ -1347,7 +1365,7 @@ span.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
@@ -1406,6 +1424,8 @@ button.create-place {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
inset: auto 0 0;
|
||||
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
||||
forwards;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
@@ -1481,7 +1501,7 @@ button.create-place {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
@@ -1512,7 +1532,7 @@ button.create-place {
|
||||
|
||||
.search-submit-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
@@ -1530,7 +1550,7 @@ button.create-place {
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
/* Search Results Popover */
|
||||
@@ -1599,7 +1619,7 @@ button.create-place {
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1651,7 +1671,7 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.place-lists-manager input[type='checkbox'] {
|
||||
@@ -1661,7 +1681,8 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.place-lists-manager .list-color {
|
||||
/* Shared List Color Dot */
|
||||
.list-color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--default-list-color);
|
||||
@@ -1672,7 +1693,7 @@ button.create-place {
|
||||
|
||||
.place-lists-manager .divider {
|
||||
height: 1px;
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1715,7 +1736,7 @@ button.create-place {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px; /* Pill shape */
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||
@@ -1727,7 +1748,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.category-chip:active {
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
@@ -1862,7 +1883,7 @@ button.create-place {
|
||||
|
||||
.photo-tag-chip {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
@@ -1872,7 +1893,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.photo-tag-chip:hover {
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.photo-tag-chip.is-selected {
|
||||
@@ -2103,7 +2124,7 @@ button.create-place {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2123,3 +2144,109 @@ button.create-place {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Snappy slide-in from left (Desktop) */
|
||||
@keyframes sidebar-slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Snappy slide-up from bottom (Mobile) */
|
||||
@keyframes sidebar-slide-up-bottom {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists Index Sidebar Menu */
|
||||
.lists-index-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lists-index-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.lists-index-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lists-index-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lists-index-count {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Centered layout when back button is present */
|
||||
.sidebar-header.has-back-btn {
|
||||
position: relative;
|
||||
justify-content: center; /* Center horizontally */
|
||||
}
|
||||
|
||||
/* Absolute positioning for buttons in centered header */
|
||||
.sidebar-header.has-back-btn .back-btn {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header.has-back-btn .close-btn {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Centralized Title text */
|
||||
.sidebar-header-text-centered {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.sidebar-header-icon-wrapper {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
margin-right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@ import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import AppHeader from '#components/app-header';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import Toast from '#components/toast';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { or } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@@ -15,16 +12,17 @@ export default class ApplicationComponent extends Component {
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
@tracked isAppMenuOpen = false;
|
||||
|
||||
get isSidebarOpen() {
|
||||
// We consider the sidebar "open" if we are in search or place routes AND it's visible.
|
||||
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
const name = this.router.currentRouteName;
|
||||
return (
|
||||
this.mapUi.isSidebarVisible &&
|
||||
(this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search')
|
||||
(name === 'place' ||
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,24 +35,27 @@ export default class ApplicationComponent extends Component {
|
||||
|
||||
@action
|
||||
toggleAppMenu() {
|
||||
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeAppMenu() {
|
||||
this.isAppMenuOpen = false;
|
||||
if (this.router.currentRouteName === 'menu') {
|
||||
this.router.transitionTo('index');
|
||||
} else {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
if (this.isAppMenuOpen) {
|
||||
this.closeAppMenu();
|
||||
} else if (
|
||||
this.router.currentRouteName === 'search' ||
|
||||
this.router.currentRouteName === 'place'
|
||||
const name = this.router.currentRouteName;
|
||||
if (
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (name === 'menu' || name.startsWith('lists')) {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,32 +67,30 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{pageTitle "Marco"}}
|
||||
|
||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||
<div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
|
||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isAppMenuOpen}}
|
||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||
{{/if}}
|
||||
<Map
|
||||
@isSidebarOpen={{this.isSidebarOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
|
||||
<Toast />
|
||||
<Toast />
|
||||
|
||||
{{outlet}}
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
1
app/templates/lists.gjs
Normal file
1
app/templates/lists.gjs
Normal file
@@ -0,0 +1 @@
|
||||
<template>{{outlet}}</template>
|
||||
89
app/templates/lists/index.gjs
Normal file
89
app/templates/lists/index.gjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
styleFor(color) {
|
||||
const finalColor =
|
||||
color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
Collections
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="places-list">
|
||||
{{#each this.storage.lists as |list|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="lists-index-item"
|
||||
{{on "click" (fn this.selectList list.id)}}
|
||||
>
|
||||
<div class="lists-index-item-left">
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<span
|
||||
class="list-color-dot"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<div class="lists-index-name">{{list.title}}</div>
|
||||
</div>
|
||||
<div class="lists-index-count">
|
||||
{{#if list.placeRefs.length}}
|
||||
{{list.placeRefs.length}}
|
||||
places
|
||||
{{else}}
|
||||
empty
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
109
app/templates/lists/list.gjs
Normal file
109
app/templates/lists/list.gjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import Component from '@glimmer/component';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class ListsListTemplate extends Component {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
|
||||
get listId() {
|
||||
return this.args.model?.listId;
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||
}
|
||||
|
||||
get listColor() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
}
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
get places() {
|
||||
const modelPlaces = this.args.model?.places || [];
|
||||
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||
const placeRefsIds = new Set(
|
||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||
);
|
||||
|
||||
// Filter live tracked savedPlaces that are in this list
|
||||
const livePlaces = this.storage.savedPlaces.filter((p) =>
|
||||
placeRefsIds.has(p.id)
|
||||
);
|
||||
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Process live state first to reflect deletions/edits immediately
|
||||
livePlaces.forEach((p) => {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
});
|
||||
|
||||
// Supplement with any model-fetched places that are still valid but not in live state yet
|
||||
modelPlaces.forEach((p) => {
|
||||
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition(
|
||||
`list-${this.listId}`,
|
||||
sidebarContent.scrollTop
|
||||
);
|
||||
}
|
||||
this.mapUi.returnToRoute = {
|
||||
name: 'lists.list',
|
||||
model: this.listId,
|
||||
};
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToLists() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.places}}
|
||||
@title={{this.listTitle}}
|
||||
@color={{this.listColor}}
|
||||
@scrollTop={{this.scrollTop}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
@onBack={{this.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
15
app/templates/menu.gjs
Normal file
15
app/templates/menu.gjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import Component from '@glimmer/component';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class MenuTemplate extends Component {
|
||||
@service router;
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
<template><AppMenu @onClose={{this.close}} /></template>
|
||||
}
|
||||
@@ -77,8 +77,14 @@ export default class PlaceTemplate extends Component {
|
||||
navigateBack(place) {
|
||||
// The sidebar calls this with null when "Back" is clicked.
|
||||
if (place === null) {
|
||||
// If we have an active route context (e.g. lists), return to it
|
||||
if (this.mapUi.returnToRoute) {
|
||||
this.mapUi.showSidebar();
|
||||
const { name, model } = this.mapUi.returnToRoute;
|
||||
this.router.transitionTo(name, model);
|
||||
}
|
||||
// If we have an active search context, return to it (UP navigation)
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
|
||||
@@ -10,6 +10,10 @@ export default class SearchTemplate extends Component {
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
|
||||
}
|
||||
this.mapUi.returnToSearch = true;
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
@@ -28,6 +32,7 @@ export default class SearchTemplate extends Component {
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.mapUi.searchResults}}
|
||||
@scrollTop={{this.mapUi.getScrollPosition "search"}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
|
||||
/*
|
||||
* Feather icons
|
||||
*/
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
@@ -40,6 +43,9 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
|
||||
/*
|
||||
* Pinhead icons
|
||||
*/
|
||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||
@@ -53,8 +59,12 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
|
||||
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
|
||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||
import classicalBuildingWithClock from '@waysidemapping/pinhead/dist/icons/classical_building_with_clock.svg?raw';
|
||||
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
||||
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
||||
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
||||
@@ -82,8 +92,13 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||
@@ -118,6 +133,9 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
/*
|
||||
* Custom/local icons
|
||||
*/
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||
@@ -144,11 +162,13 @@ const ICONS = {
|
||||
'chevron-left': chevronLeft,
|
||||
'chevron-right': chevronRight,
|
||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||
'city-gate': cityGate,
|
||||
climbing_wall: climbingWall,
|
||||
check,
|
||||
'alert-circle': alertCircle,
|
||||
'alert-triangle': alertTriangle,
|
||||
'classical-building': classicalBuilding,
|
||||
'classical-building-with-clock': classicalBuildingWithClock,
|
||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||
'commercial-building': commercialBuilding,
|
||||
@@ -185,6 +205,7 @@ const ICONS = {
|
||||
'ice-cream-on-cone': iceCreamOnCone,
|
||||
'industrial-building': industrialBuilding,
|
||||
info,
|
||||
'info-i': infoI,
|
||||
instagram,
|
||||
jewel,
|
||||
'log-in': logIn,
|
||||
@@ -193,7 +214,11 @@ const ICONS = {
|
||||
mail,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
'market-stall': marketStall,
|
||||
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||
menu,
|
||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||
'molar-tooth': molarTooth,
|
||||
'more-horizontal': moreHorizontal,
|
||||
'more-vertical': moreVertical,
|
||||
navigation,
|
||||
@@ -245,6 +270,8 @@ const ICONS = {
|
||||
winding_way_wide: windingWayWide,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
'car-and-wrench': carAndWrench,
|
||||
'castle-keep': castleKeep,
|
||||
x,
|
||||
zap,
|
||||
'loading-ring': loadingRing,
|
||||
|
||||
@@ -14,6 +14,30 @@ export function normalizeRelayUrl(url) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function uniqNormalizedRelays(relays = []) {
|
||||
return Array.from(new Set(relays.map(normalizeRelayUrl).filter(Boolean)));
|
||||
}
|
||||
|
||||
export function mergeRequiredRelays(requiredRelays = [], customRelays = []) {
|
||||
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||
const merged = [...requiredRelays.filter(Boolean)];
|
||||
|
||||
for (const relay of uniqNormalizedRelays(customRelays)) {
|
||||
if (!requiredSet.has(relay)) {
|
||||
merged.push(relay);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||
return uniqNormalizedRelays(customRelays).filter((relay) => {
|
||||
return !requiredSet.has(relay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||
|
||||
@@ -27,6 +27,8 @@ export const POI_ICON_RULES = [
|
||||
|
||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||
@@ -72,6 +74,7 @@ export const POI_ICON_RULES = [
|
||||
tags: { shop: 'beauty' },
|
||||
icon: 'fancy-mirror-with-reflection-and-stars',
|
||||
},
|
||||
{ tags: { shop: 'car_repair' }, icon: 'car-and-wrench' },
|
||||
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
||||
{ tags: { office: true }, icon: 'commercial-building' },
|
||||
@@ -103,6 +106,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
||||
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
||||
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
||||
{ tags: { tourism: 'information' }, icon: 'info-i' },
|
||||
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
||||
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
||||
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||
@@ -113,7 +117,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||
|
||||
@@ -56,15 +56,24 @@ const PLACE_TYPE_KEYS = [
|
||||
export function getPlaceType(tags) {
|
||||
if (!tags) return null;
|
||||
|
||||
let fallbackKey = null;
|
||||
|
||||
for (const key of PLACE_TYPE_KEYS) {
|
||||
const value = tags[key];
|
||||
if (value) {
|
||||
if (value === 'yes') {
|
||||
return humanizeOsmTag(key);
|
||||
if (!fallbackKey) {
|
||||
fallbackKey = key;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return humanizeOsmTag(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackKey) {
|
||||
return humanizeOsmTag(fallbackKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
|
||||
label: 'Things to do',
|
||||
icon: 'feather-camera',
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||
'["historic"]',
|
||||
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,7 +39,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DSyq2vVy.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -49,11 +49,19 @@ export class MockNostrDataService extends Service {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultReadRelays() {
|
||||
get requiredReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultWriteRelays() {
|
||||
get requiredWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get mailboxReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get mailboxWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
188
tests/integration/components/app-menu/settings/nostr-test.gjs
Normal file
188
tests/integration/components/app-menu/settings/nostr-test.gjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
@service settings;
|
||||
|
||||
requiredReadRelays = ['wss://nostr.kosmos.org'];
|
||||
requiredWriteRelays = [];
|
||||
|
||||
mailboxReadRelays = ['wss://mailbox.example.com'];
|
||||
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
|
||||
|
||||
get configuredReadRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxReadRelays,
|
||||
...(this.settings.nostrReadRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrReadRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get configuredWriteRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxWriteRelays,
|
||||
...(this.settings.nostrWriteRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrWriteRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredReadRelays,
|
||||
this.configuredReadRelays
|
||||
);
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredWriteRelays,
|
||||
this.configuredWriteRelays
|
||||
);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
function readRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[0];
|
||||
return [...list.querySelectorAll('li')];
|
||||
}
|
||||
|
||||
function writeRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[1];
|
||||
return [...list.querySelectorAll('li')];
|
||||
}
|
||||
|
||||
function rowByText(rows, text) {
|
||||
return rows.find((row) => row.textContent.includes(text));
|
||||
}
|
||||
|
||||
module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
localStorage.removeItem('marco:settings');
|
||||
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.settings = this.owner.lookup('service:settings');
|
||||
this.onChange = () => {};
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
localStorage.removeItem('marco:settings');
|
||||
});
|
||||
|
||||
async function renderAndOpenDetails(context) {
|
||||
await render(
|
||||
<template><AppMenuSettingsNostr @onChange={{this.onChange}} /></template>
|
||||
);
|
||||
await click('summary');
|
||||
return context.element;
|
||||
}
|
||||
|
||||
test('required read relay is first and non-removable', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const rows = readRows(element);
|
||||
|
||||
assert.dom(rows[0]).includesText('nostr.kosmos.org');
|
||||
|
||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||
|
||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||
});
|
||||
|
||||
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
|
||||
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.deepEqual(this.settings.nostrReadRelayExclusions, [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
});
|
||||
|
||||
test('removing custom read relay updates custom list without exclusions', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
|
||||
await click(customRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
});
|
||||
|
||||
test('adding read relay clears existing exclusion for same relay', async function (assert) {
|
||||
this.settings.update('nostrReadRelayExclusions', [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
await fillIn('#new-read-relay', 'Mailbox.EXAMPLE.com/');
|
||||
await click(element.querySelector('#new-read-relay').nextElementSibling);
|
||||
|
||||
assert.deepEqual(this.settings.nostrReadRelays, [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
});
|
||||
|
||||
test('reset read relays clears additions and exclusions', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
this.settings.update('nostrReadRelayExclusions', [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
await click(element.querySelectorAll('.reset-relays')[0]);
|
||||
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
|
||||
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||
assert.dom(requiredRow).exists();
|
||||
});
|
||||
|
||||
test('write relays are removable and mailbox delete stores exclusion', async function (assert) {
|
||||
this.settings.update('nostrWriteRelays', [
|
||||
'wss://custom-write.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const rows = writeRows(element);
|
||||
|
||||
assert.true(
|
||||
rows.every((row) => row.querySelector('.btn-remove-relay')),
|
||||
'all write relays can be removed'
|
||||
);
|
||||
|
||||
const mailboxRow = rowByText(rows, 'mailbox-write.example.com');
|
||||
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.deepEqual(this.settings.nostrWriteRelayExclusions, [
|
||||
'wss://mailbox-write.example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -334,4 +334,54 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
|
||||
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
|
||||
const place = {
|
||||
title: 'OSM Place',
|
||||
osmId: '12345',
|
||||
osmType: 'node',
|
||||
lat: 52.520008,
|
||||
lon: 13.404954,
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const osmLink = this.element.querySelector(
|
||||
'.meta-info a[href^="https://www.openstreetmap.org/node/12345"]'
|
||||
);
|
||||
assert.ok(osmLink, 'OpenStreetMap link is rendered');
|
||||
assert.strictEqual(
|
||||
osmLink.getAttribute('href'),
|
||||
'https://www.openstreetmap.org/node/12345'
|
||||
);
|
||||
|
||||
assert.dom('button.btn-link').hasText('Add a photo');
|
||||
});
|
||||
|
||||
test('it renders correct search-based OpenStreetMap link for a custom saved place', async function (assert) {
|
||||
class MockMapUi extends Service {
|
||||
currentCenter = { lat: 52.5, lon: 13.4 };
|
||||
currentZoom = 15.6;
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUi);
|
||||
|
||||
const place = {
|
||||
title: 'Custom Place',
|
||||
lat: 52.520008,
|
||||
lon: 13.404954,
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const osmLink = this.element.querySelector(
|
||||
'.meta-info a[href^="https://www.openstreetmap.org/search"]'
|
||||
);
|
||||
assert.ok(osmLink, 'OpenStreetMap search link is rendered');
|
||||
assert.strictEqual(
|
||||
osmLink.getAttribute('href'),
|
||||
'https://www.openstreetmap.org/search?lat=52.520008&lon=13.404954&zoom=16#map=16/52.50000/13.40000'
|
||||
);
|
||||
|
||||
assert.dom('button.btn-link').doesNotExist();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,4 +230,279 @@ module('Integration | Component | search-box', function (hooks) {
|
||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo(routeName, id) {
|
||||
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||
}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
// Mock Storage Service
|
||||
class MockStorageService extends Service {
|
||||
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||
savedPlaces = [
|
||||
{
|
||||
title: 'Awesome Coffee',
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
_listIds: ['favs'],
|
||||
},
|
||||
];
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search(query) {
|
||||
if (query === 'coffee') {
|
||||
return [
|
||||
{
|
||||
title: 'Awesome Coffee',
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
description: 'Duplicate to be removed',
|
||||
},
|
||||
{
|
||||
title: 'Other Coffee',
|
||||
osmId: '888',
|
||||
osmType: 'node',
|
||||
description: 'A different coffee shop',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
// Type "coffee" to trigger matches in Category, Saved, and Photon
|
||||
await fillIn('.search-input', 'coffee');
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
const resultItems = Array.from(
|
||||
this.element.querySelectorAll('.search-result-item')
|
||||
);
|
||||
|
||||
// Should be exactly 3 items:
|
||||
// 1. Category (Coffee)
|
||||
// 2. Saved (Awesome Coffee)
|
||||
// 3. Photon (Other Coffee)
|
||||
// (The Photon duplicate of "Awesome Coffee" is removed)
|
||||
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
|
||||
|
||||
// 1. Category
|
||||
assert.ok(
|
||||
resultItems[0].textContent.includes('Coffee'),
|
||||
'First item is the category match'
|
||||
);
|
||||
assert
|
||||
.dom(resultItems[0].querySelector('.result-icon svg'))
|
||||
.hasClass('feather-search', 'Category uses search icon');
|
||||
|
||||
// 2. Saved Place
|
||||
assert.ok(
|
||||
resultItems[1].textContent.includes('Awesome Coffee'),
|
||||
'Second item is the saved place match'
|
||||
);
|
||||
assert.ok(
|
||||
resultItems[1].textContent.includes('Saved place'),
|
||||
'Saved place has correct description text'
|
||||
);
|
||||
assert
|
||||
.dom(resultItems[1].querySelector('.result-icon svg'))
|
||||
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
|
||||
|
||||
// 3. Photon Match
|
||||
assert.ok(
|
||||
resultItems[2].textContent.includes('Other Coffee'),
|
||||
'Third item is the unique photon result'
|
||||
);
|
||||
|
||||
// Click the Saved Place
|
||||
await click(resultItems[1]);
|
||||
|
||||
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
|
||||
});
|
||||
|
||||
test('it requires 3 or more characters to match saved places', async function (assert) {
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo() {}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
// Mock Storage Service
|
||||
class MockStorageService extends Service {
|
||||
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||
savedPlaces = [
|
||||
{
|
||||
title: 'Awesome Coffee',
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
_listIds: ['favs'],
|
||||
},
|
||||
];
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search(query) {
|
||||
if (query === 'aw' || query === 'awe') {
|
||||
return [
|
||||
{
|
||||
title: 'Aww Some Place',
|
||||
osmId: '111',
|
||||
osmType: 'node',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
// Type "aw" (2 characters)
|
||||
await fillIn('.search-input', 'aw');
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
let resultItems = Array.from(
|
||||
this.element.querySelectorAll('.search-result-item')
|
||||
);
|
||||
|
||||
// Should only show Photon match since 'aw' is < 3 characters
|
||||
assert.strictEqual(
|
||||
resultItems.length,
|
||||
1,
|
||||
'Renders exactly 1 item for 2 chars'
|
||||
);
|
||||
assert.ok(
|
||||
resultItems[0].textContent.includes('Aww Some Place'),
|
||||
'Shows photon match'
|
||||
);
|
||||
assert.notOk(
|
||||
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||
'Saved place is NOT shown for 2 char query'
|
||||
);
|
||||
|
||||
// Type "awe" (3 characters)
|
||||
await fillIn('.search-input', 'awe');
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
resultItems = Array.from(
|
||||
this.element.querySelectorAll('.search-result-item')
|
||||
);
|
||||
|
||||
// Should now show Saved Place and Photon match
|
||||
assert.strictEqual(
|
||||
resultItems.length,
|
||||
2,
|
||||
'Renders exactly 2 items for 3 chars'
|
||||
);
|
||||
assert.ok(
|
||||
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||
'Saved place is now shown'
|
||||
);
|
||||
assert.ok(
|
||||
resultItems.some((item) =>
|
||||
item.textContent.includes('Saved place (Favorites)')
|
||||
),
|
||||
'List names are appended to the description'
|
||||
);
|
||||
});
|
||||
|
||||
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo(routeName, id) {
|
||||
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||
}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
// Mock Storage Service (Custom Place)
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'custom-1234',
|
||||
title: 'My Custom Home',
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
// Notice NO osmId or osmType
|
||||
},
|
||||
];
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
// Type 3 chars to trigger saved place match
|
||||
await fillIn('.search-input', 'cus');
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
const resultItems = Array.from(
|
||||
this.element.querySelectorAll('.search-result-item')
|
||||
);
|
||||
|
||||
// Ensure our custom place is rendered
|
||||
const customResult = resultItems.find((item) =>
|
||||
item.textContent.includes('My Custom Home')
|
||||
);
|
||||
assert.ok(customResult, 'Custom place is rendered');
|
||||
|
||||
// Click it
|
||||
await click(customResult);
|
||||
|
||||
// Verify it navigated using the internal ID, NOT a search query
|
||||
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
parsePlacePhotos,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
|
||||
module('Unit | Utility | nostr', function () {
|
||||
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
|
||||
@@ -141,4 +147,59 @@ module('Unit | Utility | nostr', function () {
|
||||
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
|
||||
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||
});
|
||||
|
||||
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||
const relays = uniqNormalizedRelays([
|
||||
'Relay.example.com',
|
||||
'wss://relay.example.com/',
|
||||
'wss://other.example.com',
|
||||
]);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://relay.example.com',
|
||||
'wss://other.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mergeRequiredRelays keeps required relays as-is and merges normalized custom relays', function (assert) {
|
||||
const relays = mergeRequiredRelays(
|
||||
['wss://required.example.com', 'required-2.example.com'],
|
||||
['required-2.example.com/', 'wss://custom.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://required.example.com',
|
||||
'required-2.example.com',
|
||||
'wss://required-2.example.com',
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('excludeRequiredRelays removes required relays from normalized custom list', function (assert) {
|
||||
const relays = excludeRequiredRelays(
|
||||
[
|
||||
'wss://required.example.com',
|
||||
'custom.example.com',
|
||||
'ws://custom2.example.com',
|
||||
],
|
||||
['wss://required.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://custom.example.com',
|
||||
'ws://custom2.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('excludeRequiredRelays trusts required list without normalizing it', function (assert) {
|
||||
const relays = excludeRequiredRelays(
|
||||
['Required.example.com', 'custom.example.com'],
|
||||
['required.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://required.example.com',
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getIconNameForTags } from 'marco/utils/osm-icons';
|
||||
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons';
|
||||
import { getIcon } from 'marco/utils/icons';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
module('Unit | Utility | osm-icons', function () {
|
||||
@@ -36,4 +37,14 @@ module('Unit | Utility | osm-icons', function () {
|
||||
let result = getIconNameForTags({ foo: 'bar' });
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
||||
for (let rule of POI_ICON_RULES) {
|
||||
let icon = getIcon(rule.icon);
|
||||
assert.ok(
|
||||
icon,
|
||||
`Icon "${rule.icon}" specified in POI_ICON_RULES should be imported and available in getIcon`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,12 @@ module('Unit | Utility | osm', function (hooks) {
|
||||
assert.strictEqual(result, 'Building');
|
||||
});
|
||||
|
||||
test('getPlaceType ignores generic "yes" values if a more specific tag is present', function (assert) {
|
||||
const tags = { historic: 'yes', building: 'tower' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, 'Tower');
|
||||
});
|
||||
|
||||
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||
// If something is both a shop and a building, it should be a shop
|
||||
const tags = { building: 'yes', shop: 'supermarket' };
|
||||
|
||||
Reference in New Issue
Block a user