Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a6304926
|
||
|
|
6fb4ecede3
|
||
|
|
77db625b11
|
||
|
|
6351cfbd07
|
||
|
|
b7e46da3e9
|
||
|
|
3233150cc4
|
||
|
|
822472a0e6
|
||
|
|
7ede81fe5f
|
||
|
|
3d2b723e05
|
||
|
|
10ecb745ee
|
||
|
|
59243d7703
|
||
|
|
5bdb90c6f0
|
||
|
|
55a4a7a2da
|
||
|
|
1de276fcc8
|
||
|
|
61242420d1
|
||
|
|
45f6f898fa
|
||
|
|
21d261ef17
|
||
|
|
3d578177ab
|
||
|
|
df9831b7c3
|
||
|
|
78687b63ff
|
||
|
|
d6e47a1b27
|
||
|
|
42cd620c48
|
||
|
|
b9e5213c27
|
||
|
|
d6fddddd4e
|
||
|
|
335ff1a9d8
|
||
|
|
9dc080b86e
|
||
|
|
a457e1521b
|
||
|
|
39ab5e2cd1
|
||
|
|
ce53d42581
|
||
|
|
387fd6efed
|
||
|
|
aaf0d8c3e9
|
||
|
|
9807a2b822
|
||
|
|
ac9870240f
|
||
|
|
1b185d1033
|
||
|
|
4d4ed8c1ea
|
||
|
|
1613ebf0bf
|
||
|
|
4cb8c94c9c
|
||
|
|
1f87c802e1
|
||
|
|
a6576fcda8
|
||
|
|
1822c4893f
|
||
|
|
39dca446ac
|
||
|
|
2e6827f0d0
|
||
|
|
8c3a805684
|
||
|
|
cf80c1eea7
|
||
|
|
126806fcd4
|
||
|
|
f48045c35d
|
||
|
|
989173a2e6
|
||
|
|
62407f5fa4
|
||
|
|
7f1c4b5f61
|
||
|
|
59e334c499
|
||
|
|
981502e293
|
||
|
|
9c5fbca72a
|
||
|
|
fd8f04f902
|
||
|
|
3e23ec161c
|
||
|
|
f2e531c0f6
|
||
|
|
96a5a6ac34
|
||
|
|
78996b6c61
|
||
|
|
bb5b69711c
|
||
|
|
ad9c489102
|
||
|
|
c11882adfb
|
||
|
|
707f4ac11c
|
||
|
|
3bada05b63
|
||
|
|
f01730fef5
|
||
|
|
448c51bab6
|
||
|
|
0bcbae374b
|
||
|
|
c33fe3b268
|
||
|
|
18bda60310
|
||
|
|
86d25dc6ba
|
||
|
|
5b8bec6a00
|
||
|
|
f2c2eb1fdc
|
||
|
|
b42c4881f6
|
||
|
|
b18e299eca
|
||
|
|
401ed41fcd
|
||
|
|
504e8fab94
|
||
|
|
76897c9e69
|
||
|
|
59bc5ca046
|
||
|
|
ef4bb8f51a
|
||
|
|
f82a797720
|
||
|
|
f9cb22ee0e
|
||
|
|
a77ea0c97d
|
||
|
|
208b77a294
|
||
|
|
ea3e4dd0dc
|
||
|
|
2c2a3e2a4c
|
||
|
|
d266bb92bd
|
||
|
|
200100686d
|
||
|
|
70d2fe1c6c
|
||
|
|
6329ad986d
|
||
|
|
bcfa81494e
|
||
|
|
bc42694707
|
||
|
|
4390b7d699
|
||
|
|
7bab8dfa09
|
||
|
|
51c9555273
|
||
|
|
632efeeab5
|
||
|
|
14827fce3e
|
||
|
|
deeea9961f
|
||
|
|
7a109c9ba5
|
||
|
|
10aae3c9b3
|
||
|
|
b492e2aa89
|
||
|
|
4c4a53ae42
|
||
|
|
a240a5d199
|
||
|
|
0332cf4c3c
|
||
|
|
59c447fe1f
|
||
|
|
1140ecfe41
|
||
|
|
60936ed2f5
|
||
|
|
ca82a029bc
|
||
|
|
0630aed73d
|
||
|
|
f27a636529
|
||
|
|
995ae95b09
|
||
|
|
0fb320d996
|
||
|
|
4f4ca827b1
|
||
|
|
c1d3f25d50
|
||
|
|
2087cfc4f7
|
||
|
|
8572032481
|
||
|
|
b4c3f5c88d
|
||
|
|
cff19980d5
|
||
|
|
cf251f702b
|
||
|
|
d2eb888dcf
|
||
|
|
a0b4a4b3f3
|
||
|
|
cb3ee48909
|
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 11
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 11
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -9,17 +9,72 @@ import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
get hasQuery() {
|
||||
return !!this.searchQuery;
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.router && typeof this.router.on === 'function') {
|
||||
this.router.on('routeDidChange', this.syncSearchQuery);
|
||||
}
|
||||
this.syncSearchQuery();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
if (this.router && typeof this.router.off === 'function') {
|
||||
this.router.off('routeDidChange', this.syncSearchQuery);
|
||||
}
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
@action
|
||||
syncSearchQuery() {
|
||||
const qp =
|
||||
this.mapUi.currentSearch || this.router?.currentRoute?.queryParams;
|
||||
if (qp?.q) {
|
||||
this.searchQuery = qp.q;
|
||||
} else if (qp?.category) {
|
||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||
this.searchQuery = category ? category.label : qp.category;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
get isSearching() {
|
||||
// 1. If we are actively focusing/typing in the search box with a query, hide pills
|
||||
if (this.mapUi.searchBoxHasFocus && this.searchQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. If we are on the search route, check loading and results status
|
||||
if (this.router?.currentRouteName === 'search') {
|
||||
if (this.mapUi.loadingState) {
|
||||
return false; // Keep pills visible while loading
|
||||
}
|
||||
return this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||
}
|
||||
|
||||
// 3. Fallback for integration tests (non-search route with a query)
|
||||
if (this.router?.currentRouteName !== 'search' && this.searchQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
get showQuickSearch() {
|
||||
const zoom = this.mapUi.currentZoom ?? 13;
|
||||
return this.settings.showQuickSearchButtons && zoom >= 12;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -54,8 +109,8 @@ export default class AppHeaderComponent extends Component {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.settings.showQuickSearchButtons}}
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
{{#if this.showQuickSearch}}
|
||||
<div class="header-center {{if this.isSearching 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
@@ -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>
|
||||
@@ -34,6 +34,15 @@ import Icon from '#components/icon';
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your
|
||||
<a
|
||||
href="https://start.nostr.net"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Nostr</a>
|
||||
identity to publish photos of places.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
@@ -91,6 +100,26 @@ import Icon from '#components/icon';
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://openfreemap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map tiles
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/hyperknot/openfreemap/blob/main/LICENSE.md"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Various
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
@@ -139,13 +168,20 @@ import Icon from '#components/icon';
|
||||
<div class="details-content">
|
||||
<p>
|
||||
<strong>Most impactful:</strong>
|
||||
Add and improve data for points of interest in
|
||||
<a
|
||||
href="https://www.openstreetmap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>OpenStreetMap</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Add and improve data for points of interest in
|
||||
<a
|
||||
href="https://www.openstreetmap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>OpenStreetMap</a>.
|
||||
</li>
|
||||
<li>
|
||||
Add place photos with your Nostr account.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Most appreciated:</strong>
|
||||
Use this app as much as you can and
|
||||
|
||||
@@ -19,6 +19,18 @@ 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" @onContributions}}>
|
||||
<Icon @name="activity" @size={{20}} />
|
||||
<span>My Contributions</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,25 @@ export default class AppMenu extends Component {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
@action
|
||||
goToSavedPlaces() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
@action
|
||||
goToContributions() {
|
||||
this.router.transitionTo('contributions');
|
||||
}
|
||||
|
||||
<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}}
|
||||
@onContributions={{this.goToContributions}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
<AppMenuSettings
|
||||
|
||||
@@ -6,6 +6,7 @@ import Icon from '#components/icon';
|
||||
import AppMenuSettingsMapUi from './settings/map-ui';
|
||||
import AppMenuSettingsApis from './settings/apis';
|
||||
import AppMenuSettingsNostr from './settings/nostr';
|
||||
import AppMenuSettingsExperimental from './settings/experimental';
|
||||
|
||||
export default class AppMenuSettings extends Component {
|
||||
@service settings;
|
||||
@@ -20,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>
|
||||
@@ -35,6 +36,7 @@ export default class AppMenuSettings extends Component {
|
||||
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class AppMenuSettingsApis extends Component {
|
||||
<Icon @name="server" @size={{20}} />
|
||||
<span>API Providers</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class AppMenuSettingsExperimental extends Component {
|
||||
@service settings;
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="alert-triangle" @size={{20}} />
|
||||
<span>Experimental</span>
|
||||
</summary>
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="experimental-enable-photo-deletion">Enable photo deletion
|
||||
(own photos)</label>
|
||||
<select
|
||||
id="experimental-enable-photo-deletion"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "experimentalEnablePhotoDeletion")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.experimentalEnablePhotoDeletion
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.experimentalEnablePhotoDeletion
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default class AppMenuSettingsMapUi extends Component {
|
||||
<Icon @name="map" @size={{20}} />
|
||||
<span>Map & UI</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="show-quick-search">Quick search buttons visible</label>
|
||||
<select
|
||||
|
||||
@@ -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
|
||||
@@ -108,7 +246,101 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<Icon @name="zap" @size={{20}} />
|
||||
<span>Nostr</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<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>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-read-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newReadRelay}}
|
||||
{{on "input" this.updateNewReadRelay}}
|
||||
{{on "keydown" this.handleReadRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasReadOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetReadRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<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>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
@@ -135,96 +367,6 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeReadRelays 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>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-read-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newReadRelay}}
|
||||
{{on "input" this.updateNewReadRelay}}
|
||||
{{on "keydown" this.handleReadRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrReadRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetReadRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeWriteRelays 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>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrWriteRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cached data</label>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
export default class ContributionPhoto extends Component {
|
||||
get item() {
|
||||
return this.args.item;
|
||||
}
|
||||
|
||||
get primaryPhoto() {
|
||||
return this.item?.photos?.[0] || null;
|
||||
}
|
||||
|
||||
get extraPhotoCount() {
|
||||
return Math.max(0, (this.item?.photos?.length || 0) - 1);
|
||||
}
|
||||
|
||||
get placeName() {
|
||||
return this.item?.placeName;
|
||||
}
|
||||
|
||||
get isNameLoading() {
|
||||
return this.item?.placeNameLoading;
|
||||
}
|
||||
|
||||
get tags() {
|
||||
// Collect unique tags across all photos in the contribution group
|
||||
const all = (this.item?.photos || []).flatMap((p) => p.tags || []);
|
||||
return [...new Set(all)];
|
||||
}
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="contribution-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="contribution-thumb">
|
||||
{{#if this.primaryPhoto}}
|
||||
<img
|
||||
src={{or this.primaryPhoto.thumbUrl this.primaryPhoto.url}}
|
||||
alt={{or this.primaryPhoto.alt "Place photo"}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (gt this.extraPhotoCount 0)}}
|
||||
<span
|
||||
class="contribution-thumb-badge"
|
||||
>+{{this.extraPhotoCount}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="contribution-info">
|
||||
<div class="contribution-place">
|
||||
{{#if this.placeName}}
|
||||
{{this.placeName}}
|
||||
{{else if this.isNameLoading}}
|
||||
<span class="contribution-name-loading">Loading…</span>
|
||||
{{else}}
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="contribution-meta">
|
||||
{{formatRelativeDate @item.createdAt}}
|
||||
{{#if (gt this.item.photos.length 1)}}
|
||||
·
|
||||
{{this.item.photos.length}}
|
||||
photos
|
||||
{{else}}
|
||||
· 1 photo
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.tags.length}}
|
||||
<div class="contribution-tags">
|
||||
{{#each this.tags as |tag|}}
|
||||
<span class="contribution-tag">{{tag}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
import ContributionPhoto from './contribution-photo';
|
||||
import Modal from './modal';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class ContributionsTimelineComponent extends Component {
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@action
|
||||
openNostrConnectModal(event) {
|
||||
event.preventDefault();
|
||||
this.isNostrConnectModalOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.closeNostrConnectModal();
|
||||
this.args.onNostrConnected?.();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<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 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
My Contributions
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else if (not @isConnected)}}
|
||||
<p class="empty-state">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
{{on "click" this.openNostrConnectModal}}
|
||||
>Connect your Nostr account</a>
|
||||
to see your contributions.
|
||||
</p>
|
||||
{{else if (not @items.length)}}
|
||||
<p class="empty-state">No contributions yet. Start by adding photos to
|
||||
places.</p>
|
||||
{{else}}
|
||||
<ul class="contributions-list">
|
||||
{{#each @items as |item|}}
|
||||
{{#if (eq item.type "photo")}}
|
||||
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.isNostrConnectModalOpen}}
|
||||
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class DropdownMenu extends Component {
|
||||
@tracked isOpen = false;
|
||||
|
||||
@action
|
||||
toggleMenu(e) {
|
||||
e?.stopPropagation();
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeMenu(e) {
|
||||
e?.stopPropagation();
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
get triggerIcon() {
|
||||
return this.args.triggerIcon || 'more-vertical';
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="dropdown-menu-container">
|
||||
<button
|
||||
class="dropdown-trigger-btn btn-press"
|
||||
type="button"
|
||||
title={{@triggerTitle}}
|
||||
{{on "click" this.toggleMenu}}
|
||||
>
|
||||
<Icon
|
||||
@name={{this.triggerIcon}}
|
||||
@size={{@iconSize}}
|
||||
@color={{@iconColor}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{{#if this.isOpen}}
|
||||
<div class="dropdown-popover {{@popoverClass}}">
|
||||
{{yield this.closeMenu}}
|
||||
</div>
|
||||
<div
|
||||
class="menu-backdrop"
|
||||
{{on "click" this.closeMenu}}
|
||||
role="button"
|
||||
></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
+49
-11
@@ -8,6 +8,7 @@ import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||
import Kinetic from 'ol/Kinetic.js';
|
||||
import View from 'ol/View.js';
|
||||
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||
import { containsExtent } from 'ol/extent.js';
|
||||
import Overlay from 'ol/Overlay.js';
|
||||
import LayerGroup from 'ol/layer/Group.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
@@ -284,6 +285,7 @@ export default class MapComponent extends Component {
|
||||
// Initialize the UI service with the map center
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
this.mapUi.updateZoom(view.getZoom());
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
@@ -664,7 +666,12 @@ export default class MapComponent extends Component {
|
||||
if (options.preventZoom) {
|
||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||
// This avoids unnecessary panning if the place is already visible.
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
// But if the place has a bbox that doesn't fit in the current view, zoom out to fit it.
|
||||
if (selected.bbox && !this.bboxFitsInView(selected.bbox)) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
}
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
@@ -677,6 +684,20 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
bboxFitsInView(bbox) {
|
||||
if (!this.mapInstance || !bbox) return true;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const size = this.mapInstance.getSize();
|
||||
const viewExtent = view.calculateExtent(size);
|
||||
|
||||
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||
const bboxExtent = [...min, ...max];
|
||||
|
||||
return containsExtent(viewExtent, bboxExtent);
|
||||
}
|
||||
|
||||
zoomToBbox(bbox) {
|
||||
if (!this.mapInstance || !bbox) return;
|
||||
|
||||
@@ -782,14 +803,19 @@ export default class MapComponent extends Component {
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
if (size[0] <= 768) {
|
||||
// On mobile, the bottom 50% is covered by the sheet.
|
||||
// We want the pin to be in the center of the TOP 50% (visible area).
|
||||
// That means the pin should be at y = height * 0.25 (25% down from top).
|
||||
// The map center is at y = height * 0.50.
|
||||
// So the pin is "above" the center by 25% of the height in pixels.
|
||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||
// We want the pin to be in the center of the TOP 50% (visible area), minus the header.
|
||||
|
||||
const height = size[1];
|
||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
||||
const headerEl = document.querySelector('.app-header');
|
||||
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||
|
||||
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
|
||||
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
|
||||
|
||||
// The map center is at y = height * 0.50.
|
||||
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
|
||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// Shift center SOUTH (decrease Y).
|
||||
@@ -849,6 +875,9 @@ export default class MapComponent extends Component {
|
||||
let targetPixelY = pixel[1];
|
||||
let needsPan = false;
|
||||
|
||||
const headerEl = document.querySelector('.app-header');
|
||||
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||
|
||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||
if (size[0] <= 768) {
|
||||
const height = size[1];
|
||||
@@ -856,7 +885,7 @@ export default class MapComponent extends Component {
|
||||
|
||||
// If in bottom half
|
||||
if (pixel[1] > splitPoint) {
|
||||
targetPixelY = height * 0.25; // Target: Center of top half
|
||||
targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
@@ -877,11 +906,10 @@ export default class MapComponent extends Component {
|
||||
|
||||
// 3. Header Logic (Any screen size)
|
||||
// Check if the (potentially new) target Y is under the header
|
||||
const headerHeight = 60;
|
||||
const minTopDistance = headerHeight + 20; // 80px
|
||||
const minTopDistance = headerHeight + 20; // Provide some padding
|
||||
|
||||
if (targetPixelY < minTopDistance) {
|
||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||
targetPixelY = minTopDistance + 30; // Move it clear of header
|
||||
needsPan = true;
|
||||
}
|
||||
|
||||
@@ -1039,6 +1067,7 @@ export default class MapComponent extends Component {
|
||||
const view = this.mapInstance.getView();
|
||||
const center = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(center[1], center[0]);
|
||||
this.mapUi.updateZoom(view.getZoom());
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
@@ -1079,6 +1108,7 @@ export default class MapComponent extends Component {
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
this.mapUi.updateBounds(bbox);
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
if (this.isDestroying || this.isDestroyed) return;
|
||||
this.nostrData.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.placesInView);
|
||||
|
||||
@@ -1144,6 +1174,8 @@ export default class MapComponent extends Component {
|
||||
this.mapUi.returnToSearch = true;
|
||||
}
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('place', place);
|
||||
};
|
||||
|
||||
@@ -1180,6 +1212,12 @@ export default class MapComponent extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||
console.debug('Clearing active search and markers on map click');
|
||||
this.router.transitionTo('index');
|
||||
return;
|
||||
}
|
||||
|
||||
// Require Zoom >= 17 for generic map searches
|
||||
// This prevents accidental searches when interacting with the map at a high level
|
||||
const currentZoom = this.mapInstance.getView().getZoom();
|
||||
|
||||
+47
-21
@@ -1,9 +1,39 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import config from 'marco/config/environment';
|
||||
import Icon from './icon';
|
||||
|
||||
const ModalContent = <template>
|
||||
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" @stopProp}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||
disabled={{@disableClose}}
|
||||
{{on "click" @close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default class Modal extends Component {
|
||||
get isTesting() {
|
||||
return config.environment === 'test';
|
||||
}
|
||||
|
||||
get destinationElement() {
|
||||
return document.getElementById('modal-portal') || document.body;
|
||||
}
|
||||
|
||||
@action
|
||||
stopProp(e) {
|
||||
e.stopPropagation();
|
||||
@@ -18,28 +48,24 @@ export default class Modal extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="modal-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" this.stopProp}}
|
||||
{{#if this.isTesting}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||
disabled={{@disableClose}}
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
{{yield}}
|
||||
</ModalContent>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -41,6 +41,40 @@ export default class NostrConnectComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async copyConnectUri() {
|
||||
const text = this.nostrAuth.connectUri;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
textArea.style.opacity = '0';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (!successful) {
|
||||
throw new Error('Fallback copy failed');
|
||||
}
|
||||
}
|
||||
this.toast.show('Connection link copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
alert('Failed to copy link');
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="nostr-connect-modal">
|
||||
<h2>Connect with Nostr</h2>
|
||||
@@ -59,7 +93,7 @@ export default class NostrConnectComponent extends Component {
|
||||
class="btn btn-outline"
|
||||
type="button"
|
||||
disabled
|
||||
title="No Nostr extension found in your browser."
|
||||
title="No Nostr extension found in your browser"
|
||||
>
|
||||
Browser Extension (Not Found)
|
||||
</button>
|
||||
@@ -79,9 +113,20 @@ export default class NostrConnectComponent extends Component {
|
||||
{{#if this.nostrAuth.isMobile}}
|
||||
<p>Waiting for you to approve the connection in your mobile signer
|
||||
app...</p>
|
||||
<div class="mobile-connect-actions">
|
||||
<a href={{this.nostrAuth.connectUri}} class="btn btn-primary">
|
||||
Open Signer App
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
type="button"
|
||||
{{on "click" this.copyConnectUri}}
|
||||
>
|
||||
Copy Connection Link
|
||||
</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>Scan this QR code with a compatible Nostr signer app (like
|
||||
Amber):</p>
|
||||
<p>Scan this QR code with a Nostr signer app (like Amber):</p>
|
||||
<div class="qr-code-container">
|
||||
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { fn } from '@ember/helper';
|
||||
import { and, eq } from 'ember-truth-helpers';
|
||||
import Blurhash from './blurhash';
|
||||
import Icon from './icon';
|
||||
import fadeInImage from '../modifiers/fade-in-image';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import config from 'marco/config/environment';
|
||||
|
||||
export default class PhotoCarousel extends Component {
|
||||
@tracked canScrollLeft = false;
|
||||
@tracked canScrollRight = false;
|
||||
|
||||
internalEventId = null;
|
||||
carouselElement = null;
|
||||
|
||||
get photos() {
|
||||
return this.args.photos || [];
|
||||
}
|
||||
|
||||
get showChevrons() {
|
||||
// Only show chevrons if there's more than one photo
|
||||
return this.photos.length > 1;
|
||||
}
|
||||
|
||||
get cannotScrollLeft() {
|
||||
return !this.canScrollLeft;
|
||||
}
|
||||
|
||||
get cannotScrollRight() {
|
||||
return !this.canScrollRight;
|
||||
}
|
||||
|
||||
get isGalleryMain() {
|
||||
return this.args.variant === 'gallery-main';
|
||||
}
|
||||
|
||||
get isGalleryThumbnails() {
|
||||
return this.args.variant === 'gallery-thumbnails';
|
||||
}
|
||||
|
||||
get variantClass() {
|
||||
return this.args.variant || 'inline';
|
||||
}
|
||||
|
||||
lastResetKey = null;
|
||||
|
||||
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
|
||||
this.lastResetKey = resetKey;
|
||||
element.scrollLeft = 0;
|
||||
setTimeout(() => this.updateScrollState(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
isProgrammaticScroll = false;
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
const isInitial = !this.lastEventId;
|
||||
this.lastEventId = eventId;
|
||||
|
||||
// Prevent feedback loop if this carousel initiated the change
|
||||
if (this.internalEventId === eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.internalEventId = eventId;
|
||||
this.isProgrammaticScroll = true;
|
||||
|
||||
const scrollAction = () => {
|
||||
const targetSlide = element.querySelector(
|
||||
`[data-event-id="${eventId}"]`
|
||||
);
|
||||
if (targetSlide) {
|
||||
if (isInitial) {
|
||||
const originalScrollBehavior = element.style.scrollBehavior;
|
||||
element.style.scrollBehavior = 'auto';
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
|
||||
// Restore smooth scroll after the jump
|
||||
setTimeout(() => {
|
||||
element.style.scrollBehavior = originalScrollBehavior;
|
||||
this.isProgrammaticScroll = false;
|
||||
}, 50);
|
||||
} else {
|
||||
// Use native CSS smooth scrolling for subsequent clicks
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
// Clear programmatic scroll flag after a delay to let scroll finish
|
||||
setTimeout(() => {
|
||||
this.isProgrammaticScroll = false;
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
this.isProgrammaticScroll = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (isInitial) {
|
||||
// Execute immediately for the first render to prevent flash
|
||||
scrollAction();
|
||||
} else {
|
||||
// Allow DOM to update first for subsequent clicks
|
||||
setTimeout(scrollAction, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setupCarousel = modifier((element) => {
|
||||
this.carouselElement = element;
|
||||
|
||||
// Defer the initial calculation slightly to ensure CSS and images have applied
|
||||
setTimeout(() => {
|
||||
this.updateScrollState();
|
||||
}, 50);
|
||||
|
||||
let resizeObserver;
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
let intersectionObserver;
|
||||
if (
|
||||
this.args.onVisiblePhotoChange &&
|
||||
window.IntersectionObserver &&
|
||||
config.environment !== 'test'
|
||||
) {
|
||||
// Set up intersection observer to track which photo is currently "most" visible
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (this.isProgrammaticScroll) return;
|
||||
|
||||
for (let entry of entries) {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||
const eventId = entry.target.dataset.eventId;
|
||||
this.internalEventId = eventId;
|
||||
const photo = this.photos.find((p) => p.eventId === eventId);
|
||||
if (photo) {
|
||||
this.args.onVisiblePhotoChange(photo);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: element,
|
||||
threshold: 0.5,
|
||||
}
|
||||
);
|
||||
|
||||
const slides = element.querySelectorAll('.carousel-slide');
|
||||
slides.forEach((slide) => intersectionObserver.observe(slide));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.unobserve(element);
|
||||
}
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
updateScrollState() {
|
||||
if (!this.carouselElement) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
|
||||
// tolerance of 1px for floating point rounding issues
|
||||
this.canScrollLeft = scrollLeft > 1;
|
||||
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||
}
|
||||
|
||||
@action
|
||||
scrollLeft() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: -this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollRight() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
handlePhotoClick(photo) {
|
||||
if (this.args.onPhotoClick) {
|
||||
this.args.onPhotoClick(photo);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.photos.length}}
|
||||
<div class="photo-carousel {{this.variantClass}}">
|
||||
<div
|
||||
class="photo-carousel-track"
|
||||
{{this.setupCarousel}}
|
||||
{{this.resetScrollPosition @resetKey}}
|
||||
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||
{{on "scroll" this.updateScrollState}}
|
||||
>
|
||||
{{#each this.photos as |photo|}}
|
||||
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
|
||||
<div
|
||||
class="carousel-slide
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}
|
||||
{{if
|
||||
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
|
||||
'active'
|
||||
}}"
|
||||
style={{photo.style}}
|
||||
data-event-id={{photo.eventId}}
|
||||
{{on "click" (fn this.handlePhotoClick photo)}}
|
||||
>
|
||||
{{#if photo.blurhash}}
|
||||
<Blurhash
|
||||
@hash={{photo.blurhash}}
|
||||
@width={{32}}
|
||||
@height={{18}}
|
||||
class="place-header-photo-blur"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isGalleryMain}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
{{else if this.isGalleryThumbnails}}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{photo.alt}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="carousel-placeholder"></div>
|
||||
</div>
|
||||
|
||||
{{#if this.showChevrons}}
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn prev
|
||||
{{if this.cannotScrollLeft 'disabled'}}"
|
||||
{{on "click" this.scrollLeft}}
|
||||
disabled={{this.cannotScrollLeft}}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<Icon
|
||||
@name="chevron-left"
|
||||
@color="currentColor"
|
||||
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn next
|
||||
{{if this.cannotScrollRight 'disabled'}}"
|
||||
{{on "click" this.scrollRight}}
|
||||
disabled={{this.cannotScrollRight}}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<Icon
|
||||
@name="chevron-right"
|
||||
@color="currentColor"
|
||||
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||
/>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import Icon from './icon';
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" @handleBackgroundClick}}
|
||||
{{@bindKeyboard @handleKeydown}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div
|
||||
class="photo-gallery-content"
|
||||
data-current-event-id={{@currentPhoto.eventId}}
|
||||
>
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if @canDeletePhoto}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn btn-text"
|
||||
{{on "click" @handleClose}}
|
||||
aria-label="Close gallery"
|
||||
title="Close"
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="white" />
|
||||
</button>
|
||||
|
||||
<div class="main-photo-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-main"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{@currentPhoto.eventId}}
|
||||
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="thumbnail-strip-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-thumbnails"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{@currentPhoto.eventId}}
|
||||
@onPhotoClick={{@selectPhoto}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default class PhotoGallery extends Component {
|
||||
get isTesting() {
|
||||
return config.environment === 'test';
|
||||
}
|
||||
|
||||
get destinationElement() {
|
||||
return document.getElementById('modal-portal') || document.body;
|
||||
}
|
||||
|
||||
@service toast;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service nostrRelay;
|
||||
@service blossom;
|
||||
@service settings;
|
||||
|
||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||
|
||||
get isCreator() {
|
||||
return (
|
||||
this.currentPhoto?.pubkey &&
|
||||
this.nostrAuth.pubkey &&
|
||||
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||
);
|
||||
}
|
||||
|
||||
get canDeletePhoto() {
|
||||
return (
|
||||
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||
);
|
||||
}
|
||||
|
||||
bindKeyboard = modifier((element, [handler]) => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
@action
|
||||
handleClose() {
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBackgroundClick(e) {
|
||||
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
|
||||
if (
|
||||
e.target.closest('.thumbnail-strip-container') ||
|
||||
e.target.closest('.carousel-nav-btn') ||
|
||||
e.target.closest('.close-btn') ||
|
||||
e.target.closest('.actions-btn-container')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
}
|
||||
|
||||
@action
|
||||
selectPhoto(photo) {
|
||||
this.currentPhoto = photo;
|
||||
}
|
||||
|
||||
@action
|
||||
handleVisiblePhotoChange(photo) {
|
||||
if (this.currentPhoto !== photo) {
|
||||
this.currentPhoto = photo;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeydown(e) {
|
||||
if (!this.args.photos || this.args.photos.length === 0) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||
this.currentPhoto = this.args.photos[currentIndex - 1];
|
||||
} else if (
|
||||
e.key === 'ArrowRight' &&
|
||||
currentIndex < this.args.photos.length - 1
|
||||
) {
|
||||
this.currentPhoto = this.args.photos[currentIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
this.toast.show('Failed to copy event ID');
|
||||
}
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (closeMenu) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
if (closeMenu) closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventId = this.currentPhoto.eventId;
|
||||
|
||||
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||
const tags = [['e', eventId]];
|
||||
|
||||
if (this.currentPhoto.placeIdentifier) {
|
||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||
}
|
||||
|
||||
const event = await EventFactory.fromKind(5)
|
||||
.content('Deleted photo')
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
// Now that the event is published, try to delete from Blossom
|
||||
const hashRegex = /[0-9a-f]{64}/i;
|
||||
|
||||
if (this.currentPhoto.url) {
|
||||
const match = this.currentPhoto.url.match(hashRegex);
|
||||
if (match) {
|
||||
try {
|
||||
await this.blossom.delete(match[0]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete main image from blossom:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentPhoto.thumbUrl) {
|
||||
const match = this.currentPhoto.thumbUrl.match(hashRegex);
|
||||
if (match) {
|
||||
try {
|
||||
await this.blossom.delete(match[0]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete thumb image from blossom:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.toast.show('Photo deleted successfully');
|
||||
|
||||
if (closeMenu) closeMenu();
|
||||
this.handleClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete photo:', e);
|
||||
this.toast.show('Failed to delete photo: ' + e.message);
|
||||
if (closeMenu) closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
{{#if this.isTesting}}
|
||||
<GalleryContent
|
||||
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||
@bindKeyboard={{this.bindKeyboard}}
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@canDeletePhoto={{this.canDeletePhoto}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
<GalleryContent
|
||||
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||
@bindKeyboard={{this.bindKeyboard}}
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@canDeletePhoto={{this.canDeletePhoto}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager';
|
||||
import PlacePhotoUpload from './place-photo-upload';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import Modal from './modal';
|
||||
import PlacePhotosCarousel from './place-photos-carousel';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import PhotoGallery from './photo-gallery';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -22,12 +23,16 @@ export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
@tracked isConnectingNostr = false;
|
||||
@tracked isGalleryOpen = false;
|
||||
@tracked selectedGalleryPhoto = null;
|
||||
@tracked isPhotoUploadModalOpen = false;
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
@tracked newlyUploadedPhotoId = null;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
|
||||
@action
|
||||
handleUploadStateChange(isActive) {
|
||||
@@ -227,7 +232,7 @@ export default class PlaceDetails extends Component {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map((p) => {
|
||||
const safeTel = p.replace(/[\s-]+/g, '');
|
||||
const safeTel = p.replace(/[\s()+.-]/g, '');
|
||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||
})
|
||||
.join('<br>')
|
||||
@@ -341,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() {
|
||||
@@ -362,6 +379,18 @@ export default class PlaceDetails extends Component {
|
||||
return !!this.place.description;
|
||||
}
|
||||
|
||||
@action
|
||||
openGallery(photo) {
|
||||
this.selectedGalleryPhoto = photo;
|
||||
this.isGalleryOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeGallery() {
|
||||
this.isGalleryOpen = false;
|
||||
this.selectedGalleryPhoto = null;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
@@ -371,11 +400,13 @@ export default class PlaceDetails extends Component {
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<PlacePhotosCarousel
|
||||
<PhotoCarousel
|
||||
@variant="inline"
|
||||
@photos={{this.photos}}
|
||||
@name={{this.name}}
|
||||
@resetKey={{this.place.osmId}}
|
||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||
@onPhotoClick={{this.openGallery}}
|
||||
/>
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
@@ -573,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" />
|
||||
@@ -609,5 +640,14 @@ export default class PlaceDetails extends Component {
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isGalleryOpen}}
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedGalleryPhoto}}
|
||||
@placeName={{this.name}}
|
||||
@onClose={{this.closeGallery}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,10 @@ import { task } from 'ember-concurrency';
|
||||
import Geohash from 'latlon-geohash';
|
||||
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||
import Icon from '#components/icon';
|
||||
import { getSuggestedPhotoTags } from '../utils/photo-tag-suggestions';
|
||||
import capitalize from '../helpers/capitalize';
|
||||
import includes from '../helpers/includes';
|
||||
import { fn } from '@ember/helper';
|
||||
import { or, not } from 'ember-truth-helpers';
|
||||
|
||||
export default class PlacePhotoUpload extends Component {
|
||||
@@ -22,6 +26,8 @@ export default class PlacePhotoUpload extends Component {
|
||||
@tracked error = '';
|
||||
@tracked isPublishing = false;
|
||||
@tracked isDragging = false;
|
||||
@tracked selectedTags = [];
|
||||
@tracked altText = '';
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -37,6 +43,10 @@ export default class PlacePhotoUpload extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
get suggestedTags() {
|
||||
return getSuggestedPhotoTags(this.place);
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
this.addFile(event.target.files[0]);
|
||||
@@ -93,11 +103,28 @@ export default class PlacePhotoUpload extends Component {
|
||||
}
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
this.selectedTags = [];
|
||||
this.altText = '';
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleTag(tag) {
|
||||
if (this.selectedTags.includes(tag)) {
|
||||
this.selectedTags = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedTags = [tag];
|
||||
}
|
||||
|
||||
@action
|
||||
updateAltText(event) {
|
||||
this.altText = event.target.value;
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (photoData.hash) {
|
||||
@@ -135,10 +162,12 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.isPublishing = true;
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
|
||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||
|
||||
for (const tag of this.selectedTags) {
|
||||
tags.push(['t', tag]);
|
||||
}
|
||||
|
||||
if (lat && lon) {
|
||||
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||
@@ -155,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
|
||||
imeta.push(`dim ${photo.dim}`);
|
||||
}
|
||||
|
||||
imeta.push('alt A photo of a place');
|
||||
const alt = this.altText.trim();
|
||||
if (alt) {
|
||||
imeta.push(`alt ${alt}`);
|
||||
}
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
@@ -174,17 +206,11 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags.push(imeta);
|
||||
|
||||
// NIP-XX draft Place Photo event
|
||||
const template = {
|
||||
kind: 360,
|
||||
content: '',
|
||||
tags,
|
||||
};
|
||||
|
||||
if (!template.created_at) {
|
||||
template.created_at = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
const event = await factory.sign(template);
|
||||
const event = await EventFactory.fromKind(360)
|
||||
.content('')
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -193,6 +219,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
// Clear out the file so user can upload more or be done
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
this.altText = '';
|
||||
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
@@ -219,11 +246,42 @@ export default class PlacePhotoUpload extends Component {
|
||||
{{/if}}
|
||||
|
||||
{{#if this.file}}
|
||||
<div class="photo-grid">
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
|
||||
{{#if this.suggestedTags.length}}
|
||||
<div class="photo-tag-suggestions">
|
||||
<p class="photo-tag-suggestions-title">
|
||||
Choose a tag/category (optional):
|
||||
</p>
|
||||
<div class="photo-tag-suggestions-list">
|
||||
{{#each this.suggestedTags as |tag|}}
|
||||
<button
|
||||
type="button"
|
||||
class="photo-tag-chip
|
||||
{{if (includes this.selectedTags tag) 'is-selected'}}"
|
||||
{{on "click" (fn this.toggleTag tag)}}
|
||||
>
|
||||
{{capitalize tag}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="photo-alt-input">Description (optional):</label>
|
||||
<input
|
||||
id="photo-alt-input"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Describe this photo"
|
||||
value={{this.altText}}
|
||||
disabled={{this.isPublishing}}
|
||||
{{on "input" this.updateAltText}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import Blurhash from './blurhash';
|
||||
import Icon from './icon';
|
||||
import fadeInImage from '../modifiers/fade-in-image';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default class PlacePhotosCarousel extends Component {
|
||||
@tracked canScrollLeft = false;
|
||||
@tracked canScrollRight = false;
|
||||
|
||||
carouselElement = null;
|
||||
|
||||
get photos() {
|
||||
return this.args.photos || [];
|
||||
}
|
||||
|
||||
get showChevrons() {
|
||||
return this.photos.length > 1;
|
||||
}
|
||||
|
||||
get cannotScrollLeft() {
|
||||
return !this.canScrollLeft;
|
||||
}
|
||||
|
||||
get cannotScrollRight() {
|
||||
return !this.canScrollRight;
|
||||
}
|
||||
|
||||
lastResetKey = null;
|
||||
|
||||
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
|
||||
this.lastResetKey = resetKey;
|
||||
element.scrollLeft = 0;
|
||||
setTimeout(() => this.updateScrollState(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
this.lastEventId = eventId;
|
||||
// Allow DOM to update first since the photo was *just* added to the store
|
||||
setTimeout(() => {
|
||||
const targetSlide = element.querySelector(
|
||||
`[data-event-id="${eventId}"]`
|
||||
);
|
||||
if (targetSlide) {
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
setupCarousel = modifier((element) => {
|
||||
this.carouselElement = element;
|
||||
|
||||
// Defer the initial calculation slightly to ensure CSS and images have applied
|
||||
setTimeout(() => {
|
||||
this.updateScrollState();
|
||||
}, 50);
|
||||
|
||||
let resizeObserver;
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.unobserve(element);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
updateScrollState() {
|
||||
if (!this.carouselElement) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
|
||||
// tolerance of 1px for floating point rounding issues
|
||||
this.canScrollLeft = scrollLeft > 1;
|
||||
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||
}
|
||||
|
||||
@action
|
||||
scrollLeft() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: -this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollRight() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.photos.length}}
|
||||
<div class="place-photos-carousel-wrapper">
|
||||
<div
|
||||
class="place-photos-carousel-track"
|
||||
{{this.setupCarousel}}
|
||||
{{this.resetScrollPosition @resetKey}}
|
||||
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||
{{on "scroll" this.updateScrollState}}
|
||||
>
|
||||
{{#each this.photos as |photo|}}
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<div
|
||||
class="carousel-slide
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
style={{photo.style}}
|
||||
data-event-id={{photo.eventId}}
|
||||
>
|
||||
{{#if photo.blurhash}}
|
||||
<Blurhash
|
||||
@hash={{photo.blurhash}}
|
||||
@width={{32}}
|
||||
@height={{18}}
|
||||
class="place-header-photo-blur"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="carousel-placeholder"></div>
|
||||
</div>
|
||||
|
||||
{{#if this.showChevrons}}
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn prev
|
||||
{{if this.cannotScrollLeft 'disabled'}}"
|
||||
{{on "click" this.scrollLeft}}
|
||||
disabled={{this.cannotScrollLeft}}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<Icon @name="chevron-left" @color="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn next
|
||||
{{if this.cannotScrollRight 'disabled'}}"
|
||||
{{on "click" this.scrollRight}}
|
||||
disabled={{this.cannotScrollRight}}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<Icon @name="chevron-right" @color="currentColor" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -190,54 +218,58 @@ export default class PlacesSidebar extends Component {
|
||||
@onSave={{this.updateBookmark}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
{{#each @places as |place|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="place-item"
|
||||
{{on "click" (fn this.selectPlace place)}}
|
||||
>
|
||||
<div class="place-name">{{or
|
||||
place.title
|
||||
place.osmTags.name
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else if (getPlaceType place.osmTags)}}
|
||||
{{getPlaceType place.osmTags}}
|
||||
{{else}}
|
||||
Saved place
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ContributionsController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service contributions;
|
||||
@service storage;
|
||||
|
||||
loadContributionsTask = task({ restartable: true }, async (pubkey) => {
|
||||
if (!pubkey) return;
|
||||
await this.contributions.load(pubkey);
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('contributions');
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this.contributions.items;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.nostrAuth.isConnected;
|
||||
}
|
||||
|
||||
@action
|
||||
selectContribution(item) {
|
||||
if (!item) return;
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('contributions', sidebarContent.scrollTop);
|
||||
}
|
||||
|
||||
this.mapUi.returnToRoute = { name: 'contributions' };
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
|
||||
this.router.transitionTo(`/place/${item.placeIdentifier}`);
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
function getPlaceTime(place) {
|
||||
const dateVal = place.createdAt;
|
||||
if (!dateVal) return 0;
|
||||
if (typeof dateVal === 'number') {
|
||||
return dateVal;
|
||||
}
|
||||
const parsed = Date.parse(dateVal);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export default class ListsListController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
}
|
||||
});
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||
}
|
||||
|
||||
get listColor() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
}
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
get places() {
|
||||
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||
const placeRefsIds = new Set(
|
||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||
);
|
||||
|
||||
// Filter live tracked savedPlaces that are in this list
|
||||
const livePlaces = this.storage.savedPlaces.filter((p) =>
|
||||
placeRefsIds.has(p.id)
|
||||
);
|
||||
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Process live state first to reflect deletions/edits immediately
|
||||
livePlaces.forEach((p) => {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
});
|
||||
|
||||
// Supplement with any background-fetched places that are still valid but not in live state yet
|
||||
this.loadedPlaces.forEach((p) => {
|
||||
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
|
||||
merged.push(p);
|
||||
seen.add(p.id);
|
||||
}
|
||||
});
|
||||
|
||||
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition(
|
||||
`list-${this.listId}`,
|
||||
sidebarContent.scrollTop
|
||||
);
|
||||
}
|
||||
this.mapUi.returnToRoute = {
|
||||
name: 'lists.list',
|
||||
model: this.listId,
|
||||
};
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToLists() {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
@service osm;
|
||||
@service photon;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service toast;
|
||||
|
||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||
|
||||
lat = null;
|
||||
@@ -8,4 +18,195 @@ export default class SearchController extends Controller {
|
||||
q = null;
|
||||
selected = null;
|
||||
category = null;
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
this.mapUi.currentSearch &&
|
||||
params.q === this.mapUi.currentSearch.q &&
|
||||
params.category === this.mapUi.currentSearch.category &&
|
||||
params.lat === this.mapUi.currentSearch.lat &&
|
||||
params.lon === this.mapUi.currentSearch.lon;
|
||||
|
||||
const hasResults =
|
||||
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||
|
||||
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
||||
if (isSameSearch && hasResults) {
|
||||
if (this.mapUi.isSidebarVisible) {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
|
||||
this.mapUi.hideSidebar();
|
||||
this.mapUi.clearSearchResults();
|
||||
|
||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
let loadingType = null;
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// We need bounds. If we have active map state, use it.
|
||||
let bounds = this.mapUi.currentBounds;
|
||||
|
||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||
// or just use a fixed box around the center.
|
||||
if (!bounds) {
|
||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||
// Let's take a safe box of ~1km radius.
|
||||
const delta = 0.01;
|
||||
bounds = {
|
||||
minLat: lat - delta,
|
||||
maxLat: lat + delta,
|
||||
minLon: lon - delta,
|
||||
maxLon: lon + delta,
|
||||
};
|
||||
}
|
||||
|
||||
pois = await this.osm.getCategoryPois(
|
||||
bounds,
|
||||
params.category,
|
||||
lat,
|
||||
lon
|
||||
);
|
||||
|
||||
// Sort by distance from center
|
||||
pois = pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name (minimum 3 characters)
|
||||
const queryLower = params.q.toLowerCase();
|
||||
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) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search request failed.', error);
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
this.mapUi.stopSearch();
|
||||
return;
|
||||
} finally {
|
||||
if (loadingType && loadingValue) {
|
||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of these are already bookmarked
|
||||
// We resolve them to the bookmark version if they exist
|
||||
pois = pois.map((p) => {
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
// 1. Exact Name Match
|
||||
let matchedPlace = pois.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||
// Note: MapComponent had logic for <=20m + type match.
|
||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||
// For now, let's stick to name or very close proximity.
|
||||
if (!matchedPlace) {
|
||||
const topCandidate = pois[0];
|
||||
if (topCandidate._distance <= 10) {
|
||||
matchedPlace = topCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPlace) {
|
||||
// Direct transition!
|
||||
this.router.replaceWith('place', matchedPlace);
|
||||
this.mapUi.stopSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.mapUi.setSearchResults(pois);
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.stopSearch();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import { capitalize as format } from '../utils/format-text';
|
||||
|
||||
export default helper(function capitalize([text]) {
|
||||
return format(text);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import { formatRelativeDate as format } from '../utils/format-text';
|
||||
|
||||
export default helper(function formatRelativeDate([timestamp]) {
|
||||
return format(timestamp);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export default helper(function includes([collection, value]) {
|
||||
if (!Array.isArray(collection)) return false;
|
||||
return collection.includes(value);
|
||||
});
|
||||
@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
|
||||
|
||||
let observer;
|
||||
|
||||
const hideBlurhash = () => {
|
||||
const parent = element.parentElement;
|
||||
const slide =
|
||||
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
|
||||
|
||||
// Only hide the blurhash if we're in the gallery-main view.
|
||||
// In the inline view, we want to keep the blurhash visible behind portrait photos
|
||||
// to fill the 16:9 container gracefully.
|
||||
if (slide && slide.closest('.photo-carousel.gallery-main')) {
|
||||
const blur = slide.querySelector('.place-header-photo-blur');
|
||||
if (blur) {
|
||||
blur.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||
if (!element.classList.contains('loaded-instant')) {
|
||||
element.classList.add('loaded');
|
||||
}
|
||||
hideBlurhash();
|
||||
};
|
||||
|
||||
element.addEventListener('load', handleLoad);
|
||||
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
|
||||
if (img.complete) {
|
||||
// Already in browser cache, skip the animation
|
||||
element.classList.add('loaded-instant');
|
||||
hideBlurhash();
|
||||
}
|
||||
|
||||
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||
|
||||
@@ -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,11 @@ 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('contributions');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ContributionsRoute extends Route {
|
||||
@service mapUi;
|
||||
@service nostrAuth;
|
||||
@service contributions;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadContributionsTask) {
|
||||
controller.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.contributions.stop();
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.clearSearchResults();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ListsIndexRoute extends Route {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
model(params) {
|
||||
// Resolve instantly so transition happens in 0ms!
|
||||
return { list_id: params.list_id };
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
console.debug('DEBUG: setupController controller is:', controller);
|
||||
console.debug(
|
||||
'DEBUG: controller.loadPlacesTask is:',
|
||||
controller?.loadPlacesTask
|
||||
);
|
||||
controller.model = model;
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadPlacesTask) {
|
||||
controller.loadPlacesTask.perform(model.list_id);
|
||||
} else {
|
||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
-4
@@ -1,6 +1,23 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
// Detects whether the fresh OSM data differs from the currently-shown model
|
||||
// by more than ~1m of coordinate drift, or in any tag. Mirrors the relevant
|
||||
// subset of `storage.refreshPlace`'s diff for the non-bookmark path.
|
||||
function hasOsmChanges(place, fresh) {
|
||||
const latDiff = Math.abs((place.lat ?? 0) - (fresh.lat ?? 0));
|
||||
const lonDiff = Math.abs((place.lon ?? 0) - (fresh.lon ?? 0));
|
||||
if (latDiff > 0.00001 || lonDiff > 0.00001) return true;
|
||||
|
||||
const oldTags = place.osmTags || {};
|
||||
const newTags = fresh.osmTags || {};
|
||||
const allKeys = new Set([...Object.keys(oldTags), ...Object.keys(newTags)]);
|
||||
for (const key of allKeys) {
|
||||
if (oldTags[key] !== newTags[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class PlaceRoute extends Route {
|
||||
@service storage;
|
||||
@service osm;
|
||||
@@ -96,6 +113,7 @@ export default class PlaceRoute extends Route {
|
||||
if (model) {
|
||||
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||
this.mapUi.selectPlace(model, options);
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = false;
|
||||
}
|
||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||
@@ -107,6 +125,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) {
|
||||
@@ -129,14 +148,32 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
if (!place || !place.osmId || !place.osmType) return;
|
||||
|
||||
// Bookmarked place — refresh via storage, which persists the update.
|
||||
if (place.id) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
// If an update occurred, refresh the map UI selection without moving
|
||||
// the camera. This ensures the sidebar shows the new data.
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-bookmarked explicit OSM place — fetch fresh and update the model
|
||||
// in-place if anything changed, so the sidebar reflects the latest OSM
|
||||
// data without requiring a re-open.
|
||||
try {
|
||||
const fresh = await this.osm.fetchOsmObject(place.osmId, place.osmType, {
|
||||
forceFresh: true,
|
||||
});
|
||||
if (fresh && hasOsmChanges(place, fresh)) {
|
||||
Object.assign(place, fresh);
|
||||
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('[place] Fresh fetch failed for', place.osmId, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
|
||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||
}
|
||||
this.mapUi.startCreating();
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
|
||||
+14
-176
@@ -1,14 +1,9 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class SearchRoute extends Route {
|
||||
@service osm;
|
||||
@service photon;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service toast;
|
||||
|
||||
queryParams = {
|
||||
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
|
||||
category: { refreshModel: true },
|
||||
};
|
||||
|
||||
async model(params) {
|
||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
let loadingType = null;
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// We need bounds. If we have active map state, use it.
|
||||
let bounds = this.mapUi.currentBounds;
|
||||
|
||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||
// or just use a fixed box around the center.
|
||||
if (!bounds) {
|
||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||
// Let's take a safe box of ~1km radius.
|
||||
const delta = 0.01;
|
||||
bounds = {
|
||||
minLat: lat - delta,
|
||||
maxLat: lat + delta,
|
||||
minLon: lon - delta,
|
||||
maxLon: lon + delta,
|
||||
};
|
||||
}
|
||||
|
||||
pois = await this.osm.getCategoryPois(
|
||||
bounds,
|
||||
params.category,
|
||||
lat,
|
||||
lon
|
||||
);
|
||||
|
||||
// Sort by distance from center
|
||||
pois = pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name
|
||||
const queryLower = params.q.toLowerCase();
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} finally {
|
||||
if (loadingType && loadingValue) {
|
||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of these are already bookmarked
|
||||
// We resolve them to the bookmark version if they exist
|
||||
pois = pois.map((p) => {
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
return pois;
|
||||
}
|
||||
|
||||
afterModel(model, transition) {
|
||||
const { q, selected } = transition.to.queryParams;
|
||||
|
||||
// Heuristic Match Logic (ported from MapComponent)
|
||||
// If 'selected' is provided (from map click), try to find that specific feature.
|
||||
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
||||
const targetName = selected || q;
|
||||
|
||||
if (targetName && model.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = model.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||
// Note: MapComponent had logic for <=20m + type match.
|
||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||
// For now, let's stick to name or very close proximity.
|
||||
if (!matchedPlace) {
|
||||
const topCandidate = model[0];
|
||||
if (topCandidate._distance <= 10) {
|
||||
matchedPlace = topCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPlace) {
|
||||
// Direct transition!
|
||||
this.router.replaceWith('place', matchedPlace);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the pulse animation since search is done (and we are staying here)
|
||||
this.mapUi.stopSearch();
|
||||
model(params) {
|
||||
// Just return params, doing the async fetch in the controller
|
||||
return params;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
// Ensure pulse is stopped if we reach here
|
||||
this.mapUi.stopSearch();
|
||||
this.mapUi.setSearchResults(model);
|
||||
|
||||
// Trigger the background task to fetch results
|
||||
controller.fetchResultsTask.perform(model);
|
||||
|
||||
// Store current search params to allow "Up" navigation from place details
|
||||
const { q, category, lat, lon } = this.paramsFor('search');
|
||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.fetchResultsTask.cancelAll();
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
error(error, transition) {
|
||||
this.mapUi.stopSearch();
|
||||
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
|
||||
if (transition) {
|
||||
transition.abort();
|
||||
}
|
||||
return false; // Prevent bubble and stop transition
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-10
@@ -35,23 +35,20 @@ export default class BlossomService extends Service {
|
||||
}
|
||||
|
||||
async _getAuthHeader(action, hash, serverUrl) {
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const serverHostname = new URL(serverUrl).hostname;
|
||||
|
||||
const authTemplate = {
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||
tags: [
|
||||
const authEvent = await EventFactory.fromKind(24242)
|
||||
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
|
||||
.modifyPublicTags(() => [
|
||||
['t', action],
|
||||
['x', hash],
|
||||
['expiration', String(now + 3600)],
|
||||
['server', serverHostname],
|
||||
],
|
||||
};
|
||||
|
||||
const authEvent = await factory.sign(authTemplate);
|
||||
])
|
||||
.created(now)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
const base64 = btoa(JSON.stringify(authEvent));
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { groupPhotoContributions } from '../utils/contributions';
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||
* timeline entries, and resolving place names from bookmarks or the OSM API.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
||||
* 2. Group events into contribution entries via `groupPhotoContributions`.
|
||||
* 3. Resolve place names from bookmarks (sync, instant) on the first render,
|
||||
* then asynchronously from the IndexedDB name cache and OSM cache.
|
||||
* 4. For unresolved names, batch-fetch from the OSM API in the background.
|
||||
* 5. Any items that can't be resolved get a fallback name so they don't stay
|
||||
* stuck in a "Loading…" state forever.
|
||||
* 6. Update `@tracked items` so the UI renders progressively.
|
||||
*
|
||||
* The persistent name cache is stored in IndexedDB (via the `localForage`
|
||||
* service) with one key per `placeIdentifier`, so partial updates don't
|
||||
* require re-serializing the whole map.
|
||||
*/
|
||||
export default class ContributionsService extends Service {
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service storage;
|
||||
@service osm;
|
||||
@service localForage;
|
||||
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
_pendingBatchPromise = null;
|
||||
_lastBatchSignature = '';
|
||||
_unresolvable = new Set();
|
||||
|
||||
/**
|
||||
* Async name resolution. Checks, in order:
|
||||
* 1. Bookmarks (sync, instant).
|
||||
* 2. The persistent IndexedDB name cache (per-entry key).
|
||||
* 3. The OSM service's IndexedDB cache (from place detail visits).
|
||||
*
|
||||
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async _resolveCachedName(entry) {
|
||||
// 1. Try bookmarks (instant)
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) return bookmark.title;
|
||||
|
||||
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
|
||||
const cachedName = await this.localForage.get(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier
|
||||
);
|
||||
if (cachedName) return cachedName;
|
||||
|
||||
// 3. Try OSM IndexedDB cache (from place detail visits)
|
||||
const cached = await this.osm.getCachedOsmObject(
|
||||
entry.osmType,
|
||||
entry.osmId
|
||||
);
|
||||
return cached?.title || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously resolves names for still-loading entries from the caches.
|
||||
* Falls through to `_maybeBatchFetchNames` for anything that remains
|
||||
* unresolved. Fire-and-forget from `_updateItems` so the list renders
|
||||
* immediately with bookmark-resolved names.
|
||||
*
|
||||
* @param {Array} entries
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _resolveFromCache(entries) {
|
||||
const pending = entries.filter((e) => e.placeNameLoading);
|
||||
if (pending.length === 0) {
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
const name = await this._resolveCachedName(entry);
|
||||
if (name) {
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Re-render with whatever resolved, then kick off the network batch for
|
||||
// entries that are still loading.
|
||||
this.items = [...this.items];
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the user's contributions and subscribes to live updates.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
*/
|
||||
async load(pubkey) {
|
||||
if (!pubkey) {
|
||||
this.items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to the user's own kind 360 events. Each update triggers a re-group
|
||||
// and place-name resolution. This mirrors the `loadPhotosForPlace` pattern.
|
||||
this._sub = this.nostrData.store
|
||||
.timeline([{ kinds: [360, 5], authors: [pubkey] }])
|
||||
.subscribe((events) => {
|
||||
this._updateItems(events);
|
||||
});
|
||||
|
||||
// Also kick off the network fetch via nostrData. This populates the store and
|
||||
// causes the subscription above to fire with fresh events.
|
||||
await this.nostrData.loadMyContributions(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the subscription and clears the timeline. Called when leaving the
|
||||
* contributions route.
|
||||
*/
|
||||
stop() {
|
||||
if (this._sub) {
|
||||
this._sub.unsubscribe();
|
||||
this._sub = null;
|
||||
}
|
||||
this.items = [];
|
||||
this._lastBatchSignature = '';
|
||||
this._pendingBatchPromise = null;
|
||||
this._unresolvable.clear();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.stop();
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
_updateItems(events) {
|
||||
// 1. Group events into contribution entries (newest-first)
|
||||
const entries = groupPhotoContributions(events);
|
||||
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) {
|
||||
entry.placeName = bookmark.title;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
|
||||
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||
// cache, then fall through to the network batch for the rest.
|
||||
void this._resolveFromCache(entries);
|
||||
}
|
||||
|
||||
_isUnresolvable(entry) {
|
||||
return this._unresolvable.has(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
_maybeBatchFetchNames(entries) {
|
||||
const unresolved = entries.filter(
|
||||
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||
);
|
||||
|
||||
// Apply fallbacks for items already marked unresolvable in this session
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
if (this.items.some((i) => !i.placeNameLoading)) {
|
||||
this.items = [...this.items];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a stable signature so we only fire one batch request per unique set
|
||||
const signature = unresolved
|
||||
.map((e) => e.placeIdentifier)
|
||||
.sort()
|
||||
.join('|');
|
||||
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||
return;
|
||||
}
|
||||
this._lastBatchSignature = signature;
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the current `items`.
|
||||
for (const item of this.items) {
|
||||
if (!item.placeNameLoading) continue;
|
||||
if (nameMap.has(item.placeIdentifier)) {
|
||||
const name = nameMap.get(item.placeIdentifier);
|
||||
item.placeName = name;
|
||||
item.placeNameLoading = false;
|
||||
} else {
|
||||
// Could not be resolved (deleted object, network error for this item).
|
||||
// Mark as unresolvable for this session and use a fallback.
|
||||
this._unresolvable.add(item.placeIdentifier);
|
||||
item.placeName = this._fallbackName(item);
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
// Trigger a re-render
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[contributions] Batch name resolution failed', e);
|
||||
// On a total failure, mark all as unresolvable and apply fallbacks
|
||||
for (const item of this.items) {
|
||||
if (item.placeNameLoading) {
|
||||
this._unresolvable.add(item.placeIdentifier);
|
||||
item.placeName = this._fallbackName(item);
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.finally(() => {
|
||||
this._pendingBatchPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
_fallbackName(item) {
|
||||
return `OSM ${item.osmType} ${item.osmId}`;
|
||||
}
|
||||
|
||||
async _batchResolveNames(entries) {
|
||||
const nameMap = new Map();
|
||||
const toFetch = [];
|
||||
|
||||
// Re-check cache in case it was populated between the trigger and now
|
||||
for (const entry of entries) {
|
||||
const cached = await this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
nameMap.set(entry.placeIdentifier, cached);
|
||||
} else {
|
||||
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length === 0) return nameMap;
|
||||
|
||||
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
||||
|
||||
// Persist each newly-resolved name to the IndexedDB name cache as its own
|
||||
// entry (per-placeIdentifier key) so we don't re-fetch next session.
|
||||
const writePromises = [];
|
||||
for (const entry of entries) {
|
||||
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||
const place = places.get(cacheKey);
|
||||
if (place?.title) {
|
||||
nameMap.set(entry.placeIdentifier, place.title);
|
||||
writePromises.push(
|
||||
this.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier,
|
||||
place.title
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(writePromises);
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default class ImageProcessorService extends Service {
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to process image: ${e.message}`);
|
||||
throw new Error(`Failed to process image: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import localforage from 'localforage';
|
||||
import Service from '@ember/service';
|
||||
|
||||
const DB_NAME = 'marco';
|
||||
|
||||
/**
|
||||
* Thin async-only wrapper around `localforage` that exposes namespaced
|
||||
* object stores under a single IndexedDB database (`marco`).
|
||||
*
|
||||
* Each "store" is a `localforage` instance created with a unique
|
||||
* `storeName`, so callers can keep data isolated (e.g. OSM cache vs.
|
||||
* contributions name cache) without managing their own connections.
|
||||
*
|
||||
* All methods return Promises — IndexedDB is inherently async, so callers
|
||||
* must `await` every read/write.
|
||||
*/
|
||||
export default class LocalForageService extends Service {
|
||||
_instances = new Map();
|
||||
|
||||
_instance(storeName) {
|
||||
let instance = this._instances.get(storeName);
|
||||
if (!instance) {
|
||||
instance = localforage.createInstance({
|
||||
name: DB_NAME,
|
||||
storeName,
|
||||
});
|
||||
this._instances.set(storeName, instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
return this._instance(storeName).getItem(key);
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
return this._instance(storeName).setItem(key, value);
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
return this._instance(storeName).removeItem(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return this._instance(storeName).keys();
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
return this._instance(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
return this._instance(storeName).iterate(fn);
|
||||
}
|
||||
}
|
||||
@@ -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,14 +10,45 @@ 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;
|
||||
@tracked searchBoxHasFocus = false;
|
||||
@tracked selectionOptions = {};
|
||||
@tracked preventNextZoom = false;
|
||||
@tracked searchResults = [];
|
||||
@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() {
|
||||
if (!this.isSidebarVisible) {
|
||||
this.isSidebarVisible = true;
|
||||
this.isSidebarOpening = true;
|
||||
setTimeout(() => {
|
||||
this.isSidebarOpening = false;
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
this.isSidebarVisible = false;
|
||||
this.isSidebarOpening = false;
|
||||
}
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
this.selectedPlace = place;
|
||||
@@ -72,6 +104,10 @@ export default class MapUiService extends Service {
|
||||
this.currentCenter = { lat, lon };
|
||||
}
|
||||
|
||||
updateZoom(zoom) {
|
||||
this.currentZoom = zoom;
|
||||
}
|
||||
|
||||
updateBounds(bounds) {
|
||||
this.currentBounds = bounds;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
||||
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://nostr.kosmos.org';
|
||||
|
||||
import { isMobile } from '../utils/device';
|
||||
|
||||
@@ -150,9 +150,6 @@ export default class NostrAuthService extends Service {
|
||||
const relay = DEFAULT_CONNECT_RELAY;
|
||||
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||
|
||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
@@ -235,9 +232,6 @@ export default class NostrAuthService extends Service {
|
||||
|
||||
const localSigner = this._getLocalSigner();
|
||||
|
||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
|
||||
+113
-31
@@ -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 = [
|
||||
@@ -29,11 +34,13 @@ export default class NostrDataService extends Service {
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
@@ -55,10 +62,11 @@ export default class NostrDataService extends Service {
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
e.kind === 360
|
||||
@@ -82,43 +90,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) {
|
||||
@@ -215,7 +242,7 @@ export default class NostrDataService extends Service {
|
||||
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
kinds: [360],
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
]);
|
||||
@@ -236,7 +263,7 @@ export default class NostrDataService extends Service {
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
@@ -253,6 +280,57 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
async loadMyContributions(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Reset state and unsubscribe from any previous subscription
|
||||
this.myContributionEvents = [];
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
|
||||
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
|
||||
|
||||
// 1. Set up reactive store subscription so the timeline updates as events arrive
|
||||
this._contributionsSub = this.store
|
||||
.timeline(filters)
|
||||
.subscribe((events) => {
|
||||
this.myContributionEvents = events;
|
||||
});
|
||||
|
||||
// 2. Populate the store from the local Nostr IDB cache (instant)
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query(filters);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request fresh events from the network in the background
|
||||
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching my contribution events:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -384,6 +462,10 @@ export default class NostrDataService extends Service {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
@@ -45,6 +45,9 @@ export default class OsmAuthService extends Service {
|
||||
clientId: clientId,
|
||||
redirectUrl: redirectUrl,
|
||||
storeRefreshToken: true,
|
||||
onAccessTokenExpiry() {
|
||||
return this.exchangeRefreshTokenForAccessToken();
|
||||
},
|
||||
},
|
||||
new MarcoOsmAuthStorage()
|
||||
);
|
||||
|
||||
+230
-20
@@ -4,12 +4,110 @@ import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class OsmService extends Service {
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
cachedPlaces = new Map();
|
||||
|
||||
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
|
||||
// `localForage` service) so that names and basic info survive across
|
||||
// sessions and can be rendered instantly without waiting on the OSM API.
|
||||
// Entries are refreshed in the background.
|
||||
static STORE_NAME = 'osm-cache';
|
||||
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
||||
|
||||
_buildCacheKey(osmType, osmId) {
|
||||
return `${osmType}:${osmId}`;
|
||||
}
|
||||
|
||||
async _readLocalCache(osmType, osmId) {
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||
if (!raw) return null;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.timestamp !== 'number' ||
|
||||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||
) {
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
try {
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _writeLocalCache(osmType, osmId, data) {
|
||||
if (!data) return;
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[osm] Failed to write IndexedDB cache entry', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lookup for an OSM place. Checks the short-lived in-memory cache
|
||||
* first, then the persistent IndexedDB cache. Returns `null` if not
|
||||
* cached.
|
||||
*
|
||||
* Use this for instant rendering (e.g. place names in a list) and fall
|
||||
* back to `fetchOsmObject` for a fresh fetch + background refresh.
|
||||
*
|
||||
* @param {string} osmType 'node' | 'way' | 'relation'
|
||||
* @param {string} osmId
|
||||
* @returns {Promise<object|null>} Normalized OSM place data
|
||||
*/
|
||||
async getCachedOsmObject(osmType, osmId) {
|
||||
if (!osmType || !osmId) return null;
|
||||
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const memoryEntry = this.cachedPlaces.get(cacheKey);
|
||||
if (
|
||||
memoryEntry &&
|
||||
Date.now() - memoryEntry.timestamp < OsmService.IN_MEMORY_TTL_MS
|
||||
) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
return this._readLocalCache(osmType, osmId);
|
||||
}
|
||||
|
||||
_storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data) {
|
||||
if (!data || !data.title) {
|
||||
console.debug(
|
||||
`[osm] Skipping cache write for ${cacheKey}: data has no title`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedPlaces.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
// Auto-evict from the short-lived in-memory cache so it doesn't grow unbounded.
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, OsmService.IN_MEMORY_TTL_MS);
|
||||
|
||||
// Fire-and-forget the persistent write — the in-memory cache covers the
|
||||
// next immediate read, and the IndexedDB write happens in the background.
|
||||
void this._writeLocalCache(osmType, osmId, data);
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -230,16 +328,57 @@ out center;
|
||||
return this.normalizePoi(data.elements[0]);
|
||||
}
|
||||
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
const cacheKey = `${osmType}:${osmId}`;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
|
||||
// Force a fresh fetch from the API, bypassing the cache. Used by callers
|
||||
// that need genuinely current data (e.g. storage.refreshPlace, which
|
||||
// diffs the bookmark against freshly-fetched OSM data).
|
||||
if (forceFresh) {
|
||||
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||
}
|
||||
|
||||
// Cached-first path: return the in-memory entry if it's still warm.
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Otherwise return the persistent IndexedDB entry and refresh in the
|
||||
// background so the next visit is fresh.
|
||||
const localCached = await this._readLocalCache(osmType, osmId);
|
||||
if (localCached) {
|
||||
console.debug(`Using IndexedDB cached OSM object for ${cacheKey}`);
|
||||
// Refresh in the background, but don't block the caller.
|
||||
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
||||
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
||||
});
|
||||
return localCached;
|
||||
}
|
||||
|
||||
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||
}
|
||||
|
||||
async _refreshOsmObject(osmId, osmType, cacheKey) {
|
||||
const fresh = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||
if (fresh) {
|
||||
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, fresh);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async _fetchAndCacheOsmObject(osmId, osmType, cacheKey) {
|
||||
const data = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||
if (data) {
|
||||
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async _fetchOsmObjectFromApi(osmId, osmType) {
|
||||
let url;
|
||||
if (osmType === 'node') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||
@@ -263,29 +402,100 @@ out center;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const normalizedData = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
osmId,
|
||||
osmType
|
||||
);
|
||||
|
||||
this.cachedPlaces.set(cacheKey, {
|
||||
data: normalizedData,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Cleanup cache entry automatically after 10 seconds
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, 10000);
|
||||
|
||||
return normalizedData;
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch OSM object:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple OSM objects, using cached data where available and
|
||||
* only making network requests for uncached IDs.
|
||||
*
|
||||
* @param {Array<{osmType: string, osmId: string}>} items
|
||||
* @returns {Promise<Map<string, object>>} Map keyed by `${osmType}:${osmId}`
|
||||
*/
|
||||
async fetchOsmObjectsBatch(items) {
|
||||
const result = new Map();
|
||||
if (!items || items.length === 0) return result;
|
||||
|
||||
// 1. Collect whatever is already cached
|
||||
const missing = [];
|
||||
for (const { osmType, osmId } of items) {
|
||||
if (!osmType || !osmId) continue;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||
if (cached) {
|
||||
result.set(cacheKey, cached);
|
||||
} else {
|
||||
missing.push({ osmType, osmId, cacheKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) return result;
|
||||
|
||||
// 2. Group missing items by OSM type for batched API requests
|
||||
const byType = new Map();
|
||||
for (const item of missing) {
|
||||
if (!byType.has(item.osmType)) byType.set(item.osmType, []);
|
||||
byType.get(item.osmType).push(item);
|
||||
}
|
||||
|
||||
// 3. Fetch each type group from the OSM API (one request per type)
|
||||
const fetchPromises = [];
|
||||
for (const [osmType, group] of byType) {
|
||||
fetchPromises.push(this._fetchOsmObjectsByType(osmType, group, result));
|
||||
}
|
||||
|
||||
await Promise.allSettled(fetchPromises);
|
||||
return result;
|
||||
}
|
||||
|
||||
async _fetchOsmObjectsByType(osmType, group, result) {
|
||||
// OSM API supports fetching multiple elements of the same type in a single
|
||||
// request using comma-separated IDs (max ~50 per request).
|
||||
//
|
||||
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
|
||||
// do NOT write these results to the general OSM cache — only the returned
|
||||
// Map is used by the caller (the contributions service manages its own
|
||||
// name cache). This prevents incomplete data from breaking place detail
|
||||
// navigation, which needs the full single-object endpoint.
|
||||
const MAX_IDS_PER_REQUEST = 50;
|
||||
|
||||
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const chunk = group.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const idsParam = chunk.map((c) => c.osmId).join(',');
|
||||
const plural = osmType === 'way' ? 'ways' : osmType + 's';
|
||||
const url = `https://www.openstreetmap.org/api/0.6/${plural}.json?${plural}=${idsParam}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[osm] Batch fetch failed for ${osmType} (${res.status})`,
|
||||
idsParam
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
for (const item of chunk) {
|
||||
const normalized = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
item.osmId,
|
||||
osmType
|
||||
);
|
||||
if (normalized) {
|
||||
result.set(item.cacheKey, normalized);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[osm] Batch fetch error for ${osmType}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOsmApiData(elements, targetId, targetType) {
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ const DEFAULT_SETTINGS = {
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrReadRelays: null,
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
export default class SettingsService extends Service {
|
||||
@@ -20,6 +23,11 @@ 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;
|
||||
|
||||
overpassApis = [
|
||||
{
|
||||
@@ -108,6 +116,10 @@ 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;
|
||||
|
||||
// Save to ensure migrated settings are stored in the new format
|
||||
this.saveSettings();
|
||||
@@ -122,6 +134,9 @@ 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));
|
||||
}
|
||||
|
||||
+43
-1
@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
@service osm;
|
||||
@service toast;
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
@tracked isWidgetOpen = false;
|
||||
isNewConnection = true;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.checkInitialConnectionState();
|
||||
|
||||
this.rs = new RemoteStorage({
|
||||
modules: [Places],
|
||||
});
|
||||
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
|
||||
if (this.isNewConnection) {
|
||||
this.toast.show('Remote storage connected', 3000);
|
||||
this.isNewConnection = false;
|
||||
}
|
||||
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
|
||||
this.loadedPrefixes = [];
|
||||
this.lists = [];
|
||||
this.initialSyncDone = false;
|
||||
this.isNewConnection = true;
|
||||
});
|
||||
|
||||
this.rs.on('sync-done', () => {
|
||||
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
checkInitialConnectionState() {
|
||||
this.isNewConnection = true;
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
const keys = [
|
||||
'remotestorage:wireclient',
|
||||
'remotestorage:dropbox',
|
||||
'remotestorage:googledrive',
|
||||
];
|
||||
for (const key of keys) {
|
||||
const data = window.localStorage.getItem(key);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed && parsed.token) {
|
||||
this.isNewConnection = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to check localStorage for existing connection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
handlePlaceChange(event) {
|
||||
const { newValue, relativePath } = event;
|
||||
|
||||
@@ -234,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);
|
||||
@@ -385,7 +426,8 @@ export default class StorageService extends Service {
|
||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||
const freshData = await this.osm.fetchOsmObject(
|
||||
place.osmId,
|
||||
place.osmType
|
||||
place.osmType,
|
||||
{ forceFresh: true }
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
|
||||
+568
-59
@@ -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;
|
||||
@@ -248,13 +245,6 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
@@ -262,6 +252,7 @@ body {
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
width: 100%;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.photo-upload-item img {
|
||||
@@ -383,7 +374,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 +451,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 +474,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 +489,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 +524,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 +555,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
@@ -594,6 +592,9 @@ body {
|
||||
padding: 0 1.4rem 1rem;
|
||||
animation: details-slide-down 0.2s ease-out;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-content details .details-content.form-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -625,7 +626,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;
|
||||
@@ -641,7 +642,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 {
|
||||
@@ -671,7 +672,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 {
|
||||
@@ -693,8 +694,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 {
|
||||
@@ -705,7 +706,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;
|
||||
@@ -751,6 +752,25 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.about-section p + ul {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.about-section ul {
|
||||
padding-inline-start: 1.2em;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.about-section li {
|
||||
padding-inline-start: 0.5rem;
|
||||
margin-block: 0.15rem;
|
||||
}
|
||||
|
||||
.about-section li::marker {
|
||||
content: '♥';
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -781,7 +801,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,
|
||||
@@ -840,9 +860,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;
|
||||
@@ -890,12 +910,15 @@ abbr[title] {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.place-photos-carousel-wrapper {
|
||||
.photo-carousel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-carousel.inline {
|
||||
margin: -1rem -1rem 1rem;
|
||||
}
|
||||
|
||||
.place-photos-carousel-track {
|
||||
.photo-carousel-track {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
@@ -904,11 +927,12 @@ abbr[title] {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.place-photos-carousel-track::-webkit-scrollbar {
|
||||
.photo-carousel-track::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
flex: 0 0 100%;
|
||||
scroll-snap-align: start;
|
||||
@@ -984,7 +1008,7 @@ abbr[title] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
|
||||
.photo-carousel:hover .carousel-nav-btn:not(.disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1006,40 +1030,40 @@ abbr[title] {
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.place-photos-carousel-track {
|
||||
.photo-carousel.inline .photo-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: #fff;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
.photo-carousel.inline .carousel-slide {
|
||||
flex: 0 0 auto;
|
||||
height: 100px;
|
||||
width: auto;
|
||||
scroll-snap-align: none;
|
||||
}
|
||||
|
||||
.carousel-slide.landscape {
|
||||
.photo-carousel.inline .carousel-slide.landscape {
|
||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||
}
|
||||
|
||||
.carousel-slide.portrait {
|
||||
.photo-carousel.inline .carousel-slide.portrait {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.carousel-placeholder {
|
||||
.photo-carousel.inline .carousel-placeholder {
|
||||
display: block;
|
||||
background-color: var(--hover-bg);
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.place-header-photo.landscape,
|
||||
.place-header-photo.portrait {
|
||||
.photo-carousel.inline .place-header-photo.landscape,
|
||||
.photo-carousel.inline .place-header-photo.portrait {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.carousel-nav-btn {
|
||||
.photo-carousel.inline .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1077,6 +1101,7 @@ abbr[title] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -1087,7 +1112,7 @@ abbr[title] {
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1096,7 +1121,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%);
|
||||
}
|
||||
@@ -1258,6 +1283,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;
|
||||
}
|
||||
@@ -1339,7 +1378,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;
|
||||
@@ -1365,10 +1404,10 @@ span.icon {
|
||||
@media (width <= 768px) {
|
||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: 50%; /* Reset desktop shift */
|
||||
top: 25%;
|
||||
top: calc(25% + 30px); /* 30px approx half header height */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,6 +1439,11 @@ button.create-place {
|
||||
inset: auto 0 0;
|
||||
}
|
||||
|
||||
.sidebar-opening .sidebar {
|
||||
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
||||
forwards;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1473,7 +1517,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;
|
||||
@@ -1499,12 +1543,11 @@ button.create-place {
|
||||
color: #5f6368;
|
||||
border-radius: 50%;
|
||||
margin-left: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.search-submit-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
@@ -1522,7 +1565,7 @@ button.create-place {
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
/* Search Results Popover */
|
||||
@@ -1591,7 +1634,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;
|
||||
@@ -1643,7 +1686,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'] {
|
||||
@@ -1653,7 +1696,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);
|
||||
@@ -1664,7 +1708,7 @@ button.create-place {
|
||||
|
||||
.place-lists-manager .divider {
|
||||
height: 1px;
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1707,7 +1751,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%);
|
||||
@@ -1719,7 +1763,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.category-chip:active {
|
||||
background: #eee;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
@@ -1773,6 +1817,13 @@ button.create-place {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-connect-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.nostr-connect-status {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
@@ -1828,6 +1879,46 @@ button.create-place {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.place-photo-upload .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions-title {
|
||||
color: #898989;
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: normal;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-tag-chip {
|
||||
background: #f8f9fa;
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.photo-tag-chip:hover {
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.photo-tag-chip.is-selected {
|
||||
background: rgb(255 204 51 / 30%);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -1866,3 +1957,421 @@ button.create-place {
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Photo Gallery */
|
||||
.photo-gallery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 90%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .close-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 10;
|
||||
color: white;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .close-btn {
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .main-photo-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .main-photo-container {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .main-photo-container img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Photo Carousel: Gallery Main Variant */
|
||||
.photo-carousel.gallery-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .photo-carousel-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .carousel-slide {
|
||||
height: 100%;
|
||||
flex: 0 0 100%;
|
||||
aspect-ratio: auto;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .place-header-photo-blur,
|
||||
.photo-carousel.gallery-main .place-header-photo.landscape,
|
||||
.photo-carousel.gallery-main .place-header-photo.portrait {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Photo Carousel: Gallery Thumbnails Variant */
|
||||
.photo-carousel.gallery-thumbnails {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .photo-carousel-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
gap: 4px;
|
||||
scroll-snap-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
scroll-snap-align: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
|
||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
|
||||
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dropdown Menu Component */
|
||||
.dropdown-menu-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 5px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
padding: 0.5rem 0;
|
||||
z-index: 3001;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Actions button in photo gallery */
|
||||
.photo-gallery-overlay .actions-btn-container {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Sidebar Loading State */
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
/* Contributions Timeline */
|
||||
.contributions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.contribution-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: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contribution-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.contribution-thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.contribution-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contribution-thumb-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background: rgb(0 0 0 / 65%);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.contribution-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.contribution-place {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contribution-name-loading {
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contribution-meta {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.contribution-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contribution-tag {
|
||||
background: #f0f0f0;
|
||||
color: #555;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -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,15 +12,18 @@ 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.
|
||||
// We consider the sidebar "open" if we are in search, menu, lists, contributions 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.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search'
|
||||
this.mapUi.isSidebarVisible &&
|
||||
(name === 'place' ||
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,25 +36,40 @@ 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.transitionTo('index');
|
||||
} else if (this.router.currentRouteName === 'place') {
|
||||
// If in place route, decide if we want to go back to search or index
|
||||
// For now, let's go to index or maybe back to search if search params exist?
|
||||
// Simplest behavior: clear selection
|
||||
this.router.transitionTo('index');
|
||||
const name = this.router.currentRouteName;
|
||||
if (
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.router.transitionTo('index');
|
||||
} else if (name === 'place') {
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,32 +81,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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import ContributionsTimeline from '#components/contributions-timeline';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<ContributionsTimeline
|
||||
@items={{@controller.items}}
|
||||
@isLoading={{@controller.loadContributionsTask.isRunning}}
|
||||
@isConnected={{@controller.isConnected}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@onSelect={{@controller.selectContribution}}
|
||||
@onBack={{@controller.backToMenu}}
|
||||
@onClose={{@controller.close}}
|
||||
@onNostrConnected={{@controller.onNostrConnected}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
<template>{{outlet}}</template>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{@controller.places}}
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -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>
|
||||
}
|
||||
+31
-10
@@ -77,8 +77,19 @@ 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;
|
||||
if (model !== undefined) {
|
||||
this.router.transitionTo(name, model);
|
||||
} else {
|
||||
this.router.transitionTo(name);
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
@@ -88,23 +99,33 @@ export default class PlaceTemplate extends Component {
|
||||
}
|
||||
} else {
|
||||
// If a place is selected (unlikely in this view, but possible if we add related links)
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
// Clear search results so we don't fall back to the list
|
||||
this.router.transitionTo('index');
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
+22
-20
@@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
||||
@name="x"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
</div>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
{{on "click" this.close}}
|
||||
><Icon @name="x" @size={{20}} @color="#333" /></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ 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;
|
||||
// We don't need to manually set currentSearch here because
|
||||
// it was already set in the route's setupController
|
||||
this.router.transitionTo('place', place);
|
||||
@@ -19,14 +25,17 @@ export default class SearchTemplate extends Component {
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
|
||||
<template>
|
||||
<PlacesSidebar
|
||||
@places={{@model}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.mapUi.searchResults}}
|
||||
@scrollTop={{this.mapUi.getScrollPosition "search"}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Utilities for grouping the user's Nostr contribution events into timeline entries.
|
||||
*
|
||||
* A contribution "entry" groups one or more related Nostr events that are part of the
|
||||
* same logical action (e.g. uploading several photos of the same place in quick
|
||||
* succession). Entries are ordered newest-first.
|
||||
*/
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
const HOUR_IN_SECONDS = 60 * 60;
|
||||
|
||||
/**
|
||||
* A single contribution timeline entry.
|
||||
*
|
||||
* `placeName` and `placeNameLoading` are tracked so that mutating them after
|
||||
* the entry has been rendered (e.g. when the background OSM batch fetch
|
||||
* resolves a place name) re-renders the consuming component. The remaining
|
||||
* fields are static data and do not need to be tracked.
|
||||
*/
|
||||
export class ContributionEntry {
|
||||
type = 'photo';
|
||||
placeIdentifier;
|
||||
osmType;
|
||||
osmId;
|
||||
photos;
|
||||
createdAt;
|
||||
eventCount;
|
||||
@tracked placeName = null;
|
||||
@tracked placeNameLoading = true;
|
||||
|
||||
constructor({
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
photos,
|
||||
createdAt,
|
||||
eventCount,
|
||||
}) {
|
||||
this.placeIdentifier = placeIdentifier;
|
||||
this.osmType = osmType;
|
||||
this.osmId = osmId;
|
||||
this.photos = photos;
|
||||
this.createdAt = createdAt;
|
||||
this.eventCount = eventCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single kind 360 (Place Photo) event's `imeta` tag into a photo object.
|
||||
* Reuses the same field shape as `parsePlacePhotos` in `utils/nostr.js` but operates
|
||||
* on a single event (the NIP mandates exactly one `imeta` tag per event).
|
||||
*
|
||||
* @param {object} event A NIP-360 (kind 360) Nostr event
|
||||
* @returns {object|null} A photo object, or null if no usable `imeta` was found
|
||||
*/
|
||||
function parsePhotoFromEvent(event) {
|
||||
const tags = event.tags || [];
|
||||
|
||||
const eventTags = tags
|
||||
.filter((t) => t[0] === 't')
|
||||
.map((t) => t[1])
|
||||
.filter(Boolean);
|
||||
|
||||
const imeta = tags.find((t) => t[0] === 'imeta');
|
||||
if (!imeta) return null;
|
||||
|
||||
let url = null;
|
||||
let thumbUrl = null;
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
let aspectRatio = 16 / 9;
|
||||
let altText = null;
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
url = tag.substring(4);
|
||||
} else if (tag.startsWith('thumb ')) {
|
||||
thumbUrl = tag.substring(6);
|
||||
} else if (tag.startsWith('blurhash ')) {
|
||||
blurhash = tag.substring(9);
|
||||
} else if (tag.startsWith('dim ')) {
|
||||
const [width, height] = tag.substring(4).split('x').map(Number);
|
||||
if (width && height) {
|
||||
aspectRatio = width / height;
|
||||
if (width > height) isLandscape = true;
|
||||
}
|
||||
} else if (tag.startsWith('alt ')) {
|
||||
const alt = tag.substring(4).trim();
|
||||
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTags,
|
||||
alt: altText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies kind 5 deletion events to a list of kind 360 events, returning the
|
||||
* surviving kind 360 events.
|
||||
*
|
||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||
* @returns {Array} Surviving kind 360 events
|
||||
*/
|
||||
function applyDeletions(events) {
|
||||
const deletedIds = new Set();
|
||||
for (const event of events) {
|
||||
if (event.kind === 5) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) deletedIds.add(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return events.filter(
|
||||
(event) => event.kind === 360 && !deletedIds.has(event.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups kind 360 (Place Photo) events into contribution entries.
|
||||
*
|
||||
* Grouping rules:
|
||||
* 1. Events are grouped by their OSM entity identifier (the `i` tag).
|
||||
* 2. Within an entity, events are sub-grouped by time proximity: a new
|
||||
* sub-group starts whenever the gap between two consecutive events
|
||||
* exceeds `thresholdHours`.
|
||||
* 3. Each sub-group becomes a single contribution entry with one or more
|
||||
* photos, sorted by `created_at` descending (newest entry first).
|
||||
*
|
||||
* @param {Array} events Mixed kind 360 / kind 5 events from the user
|
||||
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
|
||||
* @returns {Array} Sorted contribution entries (newest first)
|
||||
*/
|
||||
export function groupPhotoContributions(events, thresholdHours = 3) {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
const photoEvents = applyDeletions(events);
|
||||
if (photoEvents.length === 0) return [];
|
||||
|
||||
// Group by OSM entity identifier
|
||||
const byEntity = new Map();
|
||||
for (const event of photoEvents) {
|
||||
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
|
||||
const entityId = entityTag?.[1];
|
||||
if (!entityId) continue;
|
||||
|
||||
if (!byEntity.has(entityId)) byEntity.set(entityId, []);
|
||||
byEntity.get(entityId).push(event);
|
||||
}
|
||||
|
||||
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
|
||||
const entries = [];
|
||||
|
||||
for (const [entityId, entityEvents] of byEntity) {
|
||||
// Sort newest-first within the entity
|
||||
const sorted = [...entityEvents].sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
|
||||
// Walk newest -> oldest, starting a new sub-group when the gap to the
|
||||
// previous event exceeds the threshold.
|
||||
let currentGroup = [];
|
||||
let prevTime = null;
|
||||
|
||||
for (const event of sorted) {
|
||||
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
|
||||
entries.push(buildEntry(entityId, currentGroup));
|
||||
currentGroup = [];
|
||||
}
|
||||
currentGroup.push(event);
|
||||
prevTime = event.created_at;
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
entries.push(buildEntry(entityId, currentGroup));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries by their newest event's created_at, descending. Entries with
|
||||
// no usable photos (e.g. malformed imeta) are filtered out.
|
||||
return entries
|
||||
.filter((e) => e.photos.length > 0)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single contribution entry object from a group of events for the
|
||||
* same OSM entity.
|
||||
*
|
||||
* @param {string} placeIdentifier e.g. "osm:node:123456"
|
||||
* @param {Array} events Kind 360 events in this sub-group
|
||||
* @returns {object} Contribution entry
|
||||
*/
|
||||
function buildEntry(placeIdentifier, events) {
|
||||
const photos = events
|
||||
.map(parsePhotoFromEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const createdAt = events.reduce(
|
||||
(max, e) => (e.created_at > max ? e.created_at : max),
|
||||
0
|
||||
);
|
||||
|
||||
const [, osmType, osmId] = placeIdentifier.split(':');
|
||||
|
||||
return new ContributionEntry({
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
photos,
|
||||
createdAt,
|
||||
eventCount: events.length,
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,28 @@ export function humanizeOsmTag(text) {
|
||||
w.replace(/^\w/, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function capitalize(text) {
|
||||
if (typeof text !== 'string' || !text) return '';
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function formatRelativeDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr} hr ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function getGeohashPrefixesInBbox(bbox) {
|
||||
|
||||
// Safety check to avoid infinite loops or massive arrays if bbox is weird
|
||||
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
|
||||
console.warn(
|
||||
console.debug(
|
||||
'BBox too large for 4-char geohash scanning, aborting fine scan.'
|
||||
);
|
||||
return [];
|
||||
|
||||
+29
-2
@@ -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';
|
||||
@@ -6,6 +9,7 @@ import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||
import alertTriangle from 'feather-icons/dist/icons/alert-triangle.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import database from 'feather-icons/dist/icons/database.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
@@ -22,6 +26,8 @@ import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import moreHorizontal from 'feather-icons/dist/icons/more-horizontal.svg?raw';
|
||||
import moreVertical from 'feather-icons/dist/icons/more-vertical.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
@@ -37,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';
|
||||
@@ -50,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';
|
||||
@@ -79,6 +92,7 @@ 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';
|
||||
@@ -88,7 +102,7 @@ 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';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||
@@ -104,7 +118,7 @@ import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_
|
||||
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/open_scissors.svg?raw';
|
||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
|
||||
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||
@@ -116,8 +130,12 @@ import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
/*
|
||||
* 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,10 +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,
|
||||
@@ -184,6 +205,7 @@ const ICONS = {
|
||||
'ice-cream-on-cone': iceCreamOnCone,
|
||||
'industrial-building': industrialBuilding,
|
||||
info,
|
||||
'info-i': infoI,
|
||||
instagram,
|
||||
jewel,
|
||||
'log-in': logIn,
|
||||
@@ -197,6 +219,8 @@ const ICONS = {
|
||||
menu,
|
||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||
'molar-tooth': molarTooth,
|
||||
'more-horizontal': moreHorizontal,
|
||||
'more-vertical': moreVertical,
|
||||
navigation,
|
||||
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||
nostrich,
|
||||
@@ -243,8 +267,11 @@ const ICONS = {
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
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.
|
||||
@@ -30,6 +54,11 @@ export function parsePlacePhotos(events) {
|
||||
const allPhotos = [];
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
const eventTagValues = event.tags
|
||||
.filter((t) => t[0] === 't')
|
||||
.map((t) => t[1])
|
||||
.filter(Boolean);
|
||||
|
||||
// Find all imeta tags
|
||||
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||
for (const imeta of imetas) {
|
||||
@@ -38,6 +67,8 @@ export function parsePlacePhotos(events) {
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
let aspectRatio = 16 / 9; // default
|
||||
let altText = null;
|
||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
@@ -55,6 +86,10 @@ export function parsePlacePhotos(events) {
|
||||
isLandscape = true;
|
||||
}
|
||||
}
|
||||
} else if (tag.startsWith('alt ')) {
|
||||
const alt = tag.substring(4).trim();
|
||||
// Strip the legacy placeholder we used to write on every event
|
||||
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +103,9 @@ export function parsePlacePhotos(events) {
|
||||
blurhash,
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTagValues,
|
||||
alt: altText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +106,20 @@ 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' },
|
||||
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||
|
||||
// Historic
|
||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||
{ 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' },
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { POI_CATEGORIES } from './poi-categories';
|
||||
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||
|
||||
export const CATEGORY_TAGS = {
|
||||
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||
groceries: ['front', 'food'],
|
||||
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||
};
|
||||
|
||||
export function getSuggestedPhotoTags(place) {
|
||||
const osmTags = place?.osmTags || place?.tags || {};
|
||||
const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES);
|
||||
|
||||
const suggested = [];
|
||||
for (const categoryId of categoryIds) {
|
||||
const tags = CATEGORY_TAGS[categoryId];
|
||||
if (!Array.isArray(tags)) continue;
|
||||
for (const tag of tags) {
|
||||
if (!suggested.includes(tag)) {
|
||||
suggested.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suggested.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return suggested;
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export function getMatchingPoiCategories(osmTags, categories) {
|
||||
if (!Array.isArray(categories) || !osmTags) return [];
|
||||
|
||||
return categories.filter((category) => {
|
||||
if (!Array.isArray(category.filter)) return false;
|
||||
return category.filter.some((filterStr) =>
|
||||
matchesFilter(osmTags, filterStr)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getMatchingPoiCategoryIds(osmTags, categories) {
|
||||
return getMatchingPoiCategories(osmTags, categories).map((c) => c.id);
|
||||
}
|
||||
|
||||
function matchesFilter(osmTags, filterStr) {
|
||||
const clauses = parseOverpassClauses(filterStr);
|
||||
if (clauses.length === 0) return false;
|
||||
return clauses.every((clause) => matchesClause(osmTags, clause));
|
||||
}
|
||||
|
||||
function parseOverpassClauses(filterStr) {
|
||||
if (!filterStr) return [];
|
||||
const matches = filterStr.match(/\[[^\]]+\]/g);
|
||||
if (!matches) return [];
|
||||
|
||||
return matches
|
||||
.map((raw) => parseClause(raw.slice(1, -1).trim()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseClause(content) {
|
||||
const presenceMatch = content.match(/^"([^"]+)"$/);
|
||||
if (presenceMatch) {
|
||||
return { type: 'presence', key: presenceMatch[1] };
|
||||
}
|
||||
|
||||
const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/);
|
||||
if (equalsMatch) {
|
||||
return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] };
|
||||
}
|
||||
|
||||
const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/);
|
||||
if (regexMatch) {
|
||||
return {
|
||||
type: 'regex',
|
||||
key: regexMatch[1],
|
||||
pattern: regexMatch[2],
|
||||
regex: new RegExp(regexMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/);
|
||||
if (notRegexMatch) {
|
||||
return {
|
||||
type: 'not-regex',
|
||||
key: notRegexMatch[1],
|
||||
pattern: notRegexMatch[2],
|
||||
regex: new RegExp(notRegexMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesClause(osmTags, clause) {
|
||||
const tagValues = getTagValues(osmTags, clause.key);
|
||||
|
||||
switch (clause.type) {
|
||||
case 'presence':
|
||||
return tagValues.length > 0;
|
||||
case 'equals':
|
||||
return tagValues.some((value) => value === clause.value);
|
||||
case 'regex':
|
||||
return tagValues.some((value) => clause.regex.test(value));
|
||||
case 'not-regex':
|
||||
return (
|
||||
tagValues.length === 0 ||
|
||||
!tagValues.some((value) => clause.regex.test(value))
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTagValues(osmTags, key) {
|
||||
if (!osmTags || !key) return [];
|
||||
const rawValue = osmTags[key];
|
||||
if (rawValue === undefined || rawValue === null) return [];
|
||||
|
||||
return String(rawValue)
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -37,8 +37,6 @@ export default {
|
||||
'@babel/plugin-transform-runtime',
|
||||
{
|
||||
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
||||
useESModules: true,
|
||||
regenerator: false,
|
||||
},
|
||||
],
|
||||
...macros.babelMacros,
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import qunit from 'eslint-plugin-qunit';
|
||||
import n from 'eslint-plugin-n';
|
||||
|
||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
const esmParserOptions = {
|
||||
ecmaFeatures: { modules: true },
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<link rel="stylesheet" href="/app/styles/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
<script type="module">
|
||||
import Application from './app/app';
|
||||
import environment from './app/config/environment';
|
||||
|
||||
+55
-55
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.20.5",
|
||||
"version": "1.27.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -39,61 +39,61 @@
|
||||
"version": "pnpm build && git add release/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/eslint-parser": "^7.28.5",
|
||||
"@babel/plugin-transform-runtime": "^7.28.5",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@ember/test-helpers": "^5.4.1",
|
||||
"@ember/test-waiters": "^4.1.1",
|
||||
"@embroider/core": "^4.4.2",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/eslint-parser": "^8.0.1",
|
||||
"@babel/plugin-transform-runtime": "^8.0.1",
|
||||
"@babel/runtime": "^8.0.0",
|
||||
"@ember/test-helpers": "^5.4.3",
|
||||
"@ember/test-waiters": "^4.1.2",
|
||||
"@embroider/core": "^4.6.3",
|
||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||
"@embroider/macros": "^1.19.6",
|
||||
"@embroider/macros": "^1.20.6",
|
||||
"@embroider/router": "^3.0.6",
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "~1.2.1",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
"@warp-drive/json-api": "~5.8.0",
|
||||
"@warp-drive/legacy": "~5.8.0",
|
||||
"@warp-drive/utilities": "~5.8.0",
|
||||
"babel-plugin-ember-template-compilation": "^3.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"decorator-transforms": "^2.3.1",
|
||||
"ember-cli": "^6.10.0",
|
||||
"ember-cli-deprecation-workflow": "^4.0.0",
|
||||
"ember-modifier": "^4.2.2",
|
||||
"@embroider/vite": "^1.7.9",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@glimmer/component": "^2.1.1",
|
||||
"@remotestorage/module-places": "~1.3.0",
|
||||
"@rollup/plugin-babel": "^7.1.0",
|
||||
"@warp-drive/core": "~5.8.2",
|
||||
"@warp-drive/ember": "~5.8.2",
|
||||
"@warp-drive/json-api": "~5.8.2",
|
||||
"@warp-drive/legacy": "~5.8.2",
|
||||
"@warp-drive/utilities": "~5.8.2",
|
||||
"babel-plugin-ember-template-compilation": "^4.0.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"decorator-transforms": "^2.4.0",
|
||||
"ember-cli": "^7.1.0",
|
||||
"ember-cli-deprecation-workflow": "^4.0.1",
|
||||
"ember-modifier": "^4.3.0",
|
||||
"ember-page-title": "^9.0.3",
|
||||
"ember-qunit": "^9.0.4",
|
||||
"ember-resolver": "^13.1.1",
|
||||
"ember-source": "~6.11.0-alpha.6",
|
||||
"ember-qunit": "^9.1.0",
|
||||
"ember-resolver": "^13.2.0",
|
||||
"ember-source": "~7.2.0",
|
||||
"ember-template-lint": "^7.9.3",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"ember-welcome-page": "^8.0.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-ember": "^12.7.5",
|
||||
"eslint-plugin-n": "^17.23.1",
|
||||
"eslint-plugin-qunit": "^8.2.5",
|
||||
"eslint-plugin-warp-drive": "^5.8.0",
|
||||
"eslint-plugin-ember": "^13.5.0",
|
||||
"eslint-plugin-n": "^18.3.0",
|
||||
"eslint-plugin-qunit": "^8.2.6",
|
||||
"eslint-plugin-warp-drive": "^5.8.2",
|
||||
"feather-icons": "^4.29.2",
|
||||
"globals": "^16.5.0",
|
||||
"globals": "^17.11.0",
|
||||
"latlon-geohash": "^2.0.0",
|
||||
"ol": "^10.7.0",
|
||||
"ol-mapbox-style": "^13.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"ol": "^10.10.0",
|
||||
"ol-mapbox-style": "^13.4.2",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.7",
|
||||
"qunit": "^2.26.0",
|
||||
"qunit-dom": "^3.6.0",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"remotestoragejs": "2.0.0-beta.8",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"testem": "^3.17.0",
|
||||
"vite": "^7.3.0"
|
||||
"remotestoragejs": "2.0.0-beta.10",
|
||||
"sinon": "^22.1.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"testem": "^3.20.1",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -102,17 +102,17 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"applesauce-core": "^5.2.0",
|
||||
"applesauce-factory": "^4.0.0",
|
||||
"applesauce-relay": "^5.2.0",
|
||||
"applesauce-signers": "^5.2.0",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@waysidemapping/pinhead": "^15.25.0",
|
||||
"applesauce-core": "^6.2.0",
|
||||
"applesauce-relay": "^6.2.1",
|
||||
"applesauce-signers": "^6.2.2",
|
||||
"blurhash": "^2.0.5",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"nostr-idb": "^5.0.0",
|
||||
"oauth2-pkce": "^2.1.3",
|
||||
"ember-lifeline": "^7.1.0",
|
||||
"localforage": "^1.10.0",
|
||||
"nostr-idb": "^5.1.0",
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/async-arrow-task-transform.js b/async-arrow-task-transform.js
|
||||
index 4eb856a2a7753ef583e10e07f2b602c0c1c0c21d..6a808d035834567dcfc491ecaa7f59e77d344887 100644
|
||||
--- a/async-arrow-task-transform.js
|
||||
+++ b/async-arrow-task-transform.js
|
||||
@@ -331,7 +331,7 @@ function cleanupUnusedTaskFactoryImports(programPath, usedTaskFactories) {
|
||||
}
|
||||
|
||||
module.exports = declare((api) => {
|
||||
- api.assertVersion(7);
|
||||
+ // api.assertVersion(7);
|
||||
|
||||
return {
|
||||
name: 'transform-ember-concurrency-async-function-tasks',
|
||||
Generated
+4339
-3203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
patchedDependencies:
|
||||
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
|
||||
@@ -1 +0,0 @@
|
||||
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
|
||||
@@ -0,0 +1,182 @@
|
||||
var q = [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"*",
|
||||
"+",
|
||||
",",
|
||||
"-",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
"=",
|
||||
"?",
|
||||
"@",
|
||||
"[",
|
||||
"]",
|
||||
"^",
|
||||
"_",
|
||||
"{",
|
||||
"|",
|
||||
"}",
|
||||
"~"
|
||||
], p = (t, e) => {
|
||||
var a = "";
|
||||
for (let o = 1; o <= e; o++) {
|
||||
let h = Math.floor(t) / Math.pow(83, e - o) % 83;
|
||||
a += q[Math.floor(h)];
|
||||
}
|
||||
return a;
|
||||
}, f = (t) => {
|
||||
let e = t / 255;
|
||||
return e <= .04045 ? e / 12.92 : Math.pow((e + .055) / 1.055, 2.4);
|
||||
}, h = (t) => {
|
||||
let e = Math.max(0, Math.min(1, t));
|
||||
return e <= .0031308 ? Math.trunc(12.92 * e * 255 + .5) : Math.trunc(255 * (1.055 * Math.pow(e, .4166666666666667) - .055) + .5);
|
||||
}, M = (t, e) => ((t) => t < 0 ? -1 : 1)(t) * Math.pow(Math.abs(t), e), d = class extends Error {
|
||||
constructor(t) {
|
||||
super(t), this.name = "ValidationError", this.message = t;
|
||||
}
|
||||
}, D = (t, e, a, o) => {
|
||||
let h = 0, r = 0, n = 0, i = 4 * e;
|
||||
for (let l = 0; l < e; l++) {
|
||||
let e = 4 * l;
|
||||
for (let s = 0; s < a; s++) {
|
||||
let a = e + s * i, g = o(l, s);
|
||||
h += g * f(t[a]), r += g * f(t[a + 1]), n += g * f(t[a + 2]);
|
||||
}
|
||||
}
|
||||
let s = 1 / (e * a);
|
||||
return [
|
||||
h * s,
|
||||
r * s,
|
||||
n * s
|
||||
];
|
||||
}, S = (t, e, a, o, r) => {
|
||||
if (o < 1 || o > 9 || r < 1 || r > 9) throw new d("BlurHash must have between 1 and 9 components");
|
||||
if (e * a * 4 !== t.length) throw new d("Width and height must match the pixels array");
|
||||
let n = [];
|
||||
for (let h = 0; h < r; h++) for (let r = 0; r < o; r++) {
|
||||
let o = 0 == r && 0 == h ? 1 : 2, i = D(t, e, a, (t, n) => o * Math.cos(Math.PI * r * t / e) * Math.cos(Math.PI * h * n / a));
|
||||
n.push(i);
|
||||
}
|
||||
let i, s = n[0], l = n.slice(1), g = "";
|
||||
if (g += p(o - 1 + 9 * (r - 1), 1), l.length > 0) {
|
||||
let t = Math.max(...l.map((t) => Math.max(...t))), e = Math.floor(Math.max(0, Math.min(82, Math.floor(166 * t - .5))));
|
||||
i = (e + 1) / 166, g += p(e, 1);
|
||||
} else i = 1, g += p(0, 1);
|
||||
return g += p(((t) => (h(t[0]) << 16) + (h(t[1]) << 8) + h(t[2]))(s), 4), l.forEach((t) => {
|
||||
g += p(((t, e) => 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[0] / e, .5) + 9.5)))) * 19 + 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[1] / e, .5) + 9.5)))) + Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[2] / e, .5) + 9.5)))))(t, i), 2);
|
||||
}), g;
|
||||
};
|
||||
self.onmessage = async (t) => {
|
||||
if ("PROCESS_IMAGE" !== t.data?.type) return;
|
||||
const { id: e, file: a, targetWidth: o, targetHeight: h, quality: r, computeBlurhash: n } = t.data;
|
||||
try {
|
||||
let t, l;
|
||||
try {
|
||||
const e = await createImageBitmap(a, {
|
||||
resizeWidth: o,
|
||||
resizeHeight: h,
|
||||
resizeQuality: "high"
|
||||
});
|
||||
if (t = new OffscreenCanvas(o, h), l = t.getContext("2d"), !l) throw new Error("Failed to get 2d context from OffscreenCanvas");
|
||||
l.drawImage(e, 0, 0, o, h), e.close();
|
||||
} catch (i) {
|
||||
console.warn("Hardware resize failed, falling back to stepped software scaling:", i);
|
||||
const e = await a.arrayBuffer(), r = new Blob([e], { type: a.type }), n = await createImageBitmap(r);
|
||||
let s = n.width, g = n.height, f = new OffscreenCanvas(s, g), c = f.getContext("2d");
|
||||
for (c.imageSmoothingEnabled = !0, c.imageSmoothingQuality = "high", c.drawImage(n, 0, 0); .5 * f.width > o && .5 * f.height > h;) {
|
||||
const t = new OffscreenCanvas(Math.floor(.5 * f.width), Math.floor(.5 * f.height)), e = t.getContext("2d");
|
||||
e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high", e.drawImage(f, 0, 0, t.width, t.height), f = t;
|
||||
}
|
||||
t = new OffscreenCanvas(o, h), l = t.getContext("2d"), l.imageSmoothingEnabled = !0, l.imageSmoothingQuality = "high", l.drawImage(f, 0, 0, o, h), n.close();
|
||||
}
|
||||
let g = null;
|
||||
if (n) try {
|
||||
const t = l.getImageData(0, 0, o, h);
|
||||
g = S(t.data, o, h, 4, 3);
|
||||
} catch (s) {
|
||||
console.warn("Could not generate blurhash (possible canvas fingerprinting protection):", s);
|
||||
}
|
||||
const f = await t.convertToBlob({
|
||||
type: "image/jpeg",
|
||||
quality: r
|
||||
}), c = `${o}x${h}`;
|
||||
self.postMessage({
|
||||
id: e,
|
||||
success: !0,
|
||||
blob: f,
|
||||
dim: c,
|
||||
blurhash: g
|
||||
});
|
||||
} catch (l) {
|
||||
self.postMessage({
|
||||
id: e,
|
||||
success: !1,
|
||||
error: l.message
|
||||
});
|
||||
}
|
||||
};
|
||||
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
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
@@ -39,9 +39,10 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-Dhq0XoTm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css">
|
||||
<script type="module" crossorigin src="/assets/main-D3dQ-CGf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-P9lvOtv_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async fetchOsmObject() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-123',
|
||||
title: 'Mountain Trail',
|
||||
geohash: 'u33dc0',
|
||||
osmTags: { name: 'Mountain Trail' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
|
||||
},
|
||||
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
if (id === 'place-123') {
|
||||
return this.savedPlaces[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve([this.savedPlaces[0]]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
module('Acceptance | collections navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
});
|
||||
|
||||
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
|
||||
// 1. Visit Home Map
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// 2. Open the App Menu overlay
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
assert
|
||||
.dom('.app-menu')
|
||||
.includesText('Collections', 'Menu contains Collections link');
|
||||
|
||||
// 3. Transition to Collections Index (List of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('Collections', 'Header is centered and titled Collections');
|
||||
assert
|
||||
.dom('.lists-index-item')
|
||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
||||
|
||||
// 4. Transition to a specific list (Want to go)
|
||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Transitions instantly to /lists/to-go'
|
||||
);
|
||||
|
||||
// 5. Verify background loading spinner shows up, then results populate
|
||||
await waitFor('.places-list');
|
||||
assert
|
||||
.dom('.places-list .place-name')
|
||||
.hasText('Mountain Trail', 'Renders the saved place from the list');
|
||||
assert
|
||||
.dom('.places-list .place-type')
|
||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||
|
||||
// 6. Click back button in collection list header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||
|
||||
// 7. Click back button in collections index header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||
|
||||
// 8. Close sidebar
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
||||
});
|
||||
|
||||
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Click on the place item to view details
|
||||
await click('.place-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/place-123'),
|
||||
'Transitions to place details route'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Returns gracefully back to lists/to-go list view'
|
||||
);
|
||||
});
|
||||
|
||||
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
||||
class SortedMockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-oldest',
|
||||
title: 'Oldest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-01T12:00:00.000Z',
|
||||
osmTags: { name: 'Oldest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-newest',
|
||||
title: 'Newest Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-03T12:00:00.000Z',
|
||||
osmTags: { name: 'Newest Place' },
|
||||
},
|
||||
{
|
||||
id: 'place-middle',
|
||||
title: 'Middle Place',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-02T12:00:00.000Z',
|
||||
updatedAt: '2023-01-04T12:00:00.000Z',
|
||||
osmTags: { name: 'Middle Place' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [
|
||||
{ id: 'place-oldest', geohash: 'u33dc0' },
|
||||
{ id: 'place-newest', geohash: 'u33dc0' },
|
||||
{ id: 'place-middle', geohash: 'u33dc0' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve(this.savedPlaces);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', SortedMockStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const placeNames = Array.from(
|
||||
document.querySelectorAll('.places-list .place-name')
|
||||
).map((el) => el.textContent.trim());
|
||||
|
||||
assert.deepEqual(
|
||||
placeNames,
|
||||
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||
'Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
fetchOsmObjectCalls = [];
|
||||
|
||||
async fetchOsmObject(id, type) {
|
||||
this.fetchOsmObjectCalls.push({ id, type });
|
||||
return {
|
||||
osmId: String(id),
|
||||
osmType: type,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
|
||||
getCachedOsmObject() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
get placesInView() {
|
||||
return [];
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
class MockContributionsService extends Service {
|
||||
@tracked items = [
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:123',
|
||||
osmType: 'node',
|
||||
osmId: '123',
|
||||
placeName: 'Test Place',
|
||||
placeNameLoading: false,
|
||||
createdAt: 2000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async load() {}
|
||||
stop() {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked pubkey = 'test-pubkey';
|
||||
|
||||
get isConnected() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module('Acceptance | contributions navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:contributions', MockContributionsService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
});
|
||||
|
||||
test('navigating from menu to contributions and back to menu', async function (assert) {
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// Open the app menu
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
|
||||
// Click "My Contributions"
|
||||
const buttons = document.querySelectorAll('.app-menu button');
|
||||
const contributionsBtn = Array.from(buttons).find((b) =>
|
||||
b.textContent.includes('My Contributions')
|
||||
);
|
||||
await click(contributionsBtn);
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/contributions',
|
||||
'Transitions to /contributions'
|
||||
);
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('My Contributions', 'Header shows the title');
|
||||
|
||||
// Contribution item should be visible
|
||||
await waitFor('.contribution-item');
|
||||
assert.dom('.contribution-place').includesText('Test Place');
|
||||
|
||||
// Click back button -> returns to menu
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to /menu');
|
||||
});
|
||||
|
||||
test('clicking a contribution opens place details and back returns to contributions', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
// Click the contribution item
|
||||
await click('.contribution-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
'Transitions to place details'
|
||||
);
|
||||
assert.deepEqual(
|
||||
mapUi.returnToRoute,
|
||||
{ name: 'contributions' },
|
||||
'returnToRoute is set to contributions'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/contributions',
|
||||
'Returns to contributions list'
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the contributions sidebar returns to index', async function (assert) {
|
||||
await visit('/contributions');
|
||||
await waitFor('.sidebar');
|
||||
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Returns to index');
|
||||
});
|
||||
|
||||
test('contributions route saves scroll position when selecting a place', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
// The scroll position key should start at 0
|
||||
assert.strictEqual(
|
||||
mapUi.getScrollPosition('contributions'),
|
||||
0,
|
||||
'Initial scroll position is 0'
|
||||
);
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
// After navigating to a place, the scroll position should have been saved
|
||||
// (0 in this case since the sidebar wasn't scrolled, but the key should exist)
|
||||
assert.ok(
|
||||
mapUi.returnToRoute,
|
||||
'returnToRoute is set when selecting a contribution'
|
||||
);
|
||||
});
|
||||
|
||||
test('selecting a contribution runs the model hook so the place has lat/lon', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
const osm = this.owner.lookup('service:osm');
|
||||
|
||||
await visit('/contributions');
|
||||
await waitFor('.contribution-item');
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
'Transitions to place details via URL'
|
||||
);
|
||||
|
||||
// The model hook should have called fetchOsmObject (not skipped it)
|
||||
assert.ok(
|
||||
osm.fetchOsmObjectCalls.length > 0,
|
||||
'OSM fetchOsmObject was called (model hook ran)'
|
||||
);
|
||||
|
||||
// The selected place should have lat/lon so the map can pan
|
||||
assert.ok(
|
||||
mapUi.selectedPlace?.lat && mapUi.selectedPlace?.lon,
|
||||
'Selected place has lat/lon coordinates'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { module, test } from 'qunit';
|
||||
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Acceptance | map search reset', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
@@ -17,58 +16,10 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
// Stub window.fetch using Sinon
|
||||
// We want to intercept map style requests and let everything else through
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Liberty',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#123456',
|
||||
},
|
||||
},
|
||||
],
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to the original implementation
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
// Restore the original fetch
|
||||
this.fetchStub.restore();
|
||||
});
|
||||
|
||||
test('clicking the map clears the category search parameter', async function (assert) {
|
||||
@@ -140,15 +91,17 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Wait for transition to index
|
||||
// Wait for transition or UI update
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/',
|
||||
'Should have transitioned to index (closed sidebar)'
|
||||
|
||||
// Sidebar should be hidden, but we should stay on the search route
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.ok(
|
||||
currentURL().includes('category=coffee'),
|
||||
'Should have stayed on the search route with markers intact'
|
||||
);
|
||||
|
||||
// Second Click (Start new search)
|
||||
// Second Click (Clear search and markers)
|
||||
// Click slightly differently to ensure fresh event
|
||||
await triggerEvent(canvas, 'pointerdown', {
|
||||
clientX: 250,
|
||||
@@ -172,11 +125,6 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
// 3. Wait for transition
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const newUrl = currentURL();
|
||||
assert.notOk(
|
||||
newUrl.includes('category=coffee'),
|
||||
`New URL ${newUrl} should not contain category param`
|
||||
);
|
||||
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
||||
assert.strictEqual(currentURL(), '/', 'Should have transitioned to index');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ class MockOsmService extends Service {
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
@@ -84,7 +85,7 @@ module('Acceptance | navigation', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||
test('closing the sidebar transitions back to search route when opened from search results', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
@@ -95,8 +96,23 @@ module('Acceptance | navigation', function (hooks) {
|
||||
// Click the Close (X) button
|
||||
await click('.close-btn');
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/search?lat=1&lon=1',
|
||||
'Should transition back to search route'
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar when visiting a place directly transitions to index', async function (assert) {
|
||||
await visit('/place/osm:node:123');
|
||||
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
||||
|
||||
// Click the Close (X) button
|
||||
await click('.close-btn');
|
||||
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.strictEqual(currentURL(), '/', 'Should transition back to index');
|
||||
});
|
||||
|
||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
||||
import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { Promise } from 'rsvp';
|
||||
|
||||
let photonResolve;
|
||||
let osmResolve;
|
||||
|
||||
class MockPhotonService extends Service {
|
||||
cancelAll() {}
|
||||
|
||||
async search(query) {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (query === 'slow') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
// Return a promise that we can manually resolve in the test
|
||||
// to avoid race conditions with native setTimeout
|
||||
return new Promise((resolve) => {
|
||||
photonResolve = () => {
|
||||
resolve([
|
||||
{
|
||||
title: 'Test Place',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
},
|
||||
]);
|
||||
};
|
||||
});
|
||||
}
|
||||
return [
|
||||
{
|
||||
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
|
||||
cancelAll() {}
|
||||
|
||||
async getCategoryPois(bounds, category) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (category === 'slow_category') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return new Promise((resolve) => {
|
||||
osmResolve = () => {
|
||||
resolve([]);
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
photonResolve = null;
|
||||
osmResolve = null;
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is set for text search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise so the task can finish deterministically
|
||||
photonResolve();
|
||||
|
||||
await searchPromise;
|
||||
await settled(); // Wait for ember-concurrency tasks to fully settle
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is set for category search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise
|
||||
osmResolve();
|
||||
|
||||
await catPromise;
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
|
||||
|
||||
// 4. Click the clear button (should be visible since input has value)
|
||||
await click('.search-clear-btn');
|
||||
// Wait for the click and transition to settle
|
||||
|
||||
// Verify loading state is cleared immediately
|
||||
assert.strictEqual(
|
||||
@@ -130,7 +161,85 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is cleared immediately after clicking clear'
|
||||
);
|
||||
|
||||
// Clean up the dangling promise
|
||||
if (photonResolve) {
|
||||
photonResolve();
|
||||
}
|
||||
|
||||
// Verify we are back on index (or at least query is gone)
|
||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||
});
|
||||
|
||||
test('quick search pills visibility during category search transition', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
mapUi.currentZoom = 15;
|
||||
|
||||
// Seed localStorage with a high zoom level to ensure quick search buttons show
|
||||
const highZoomState = {
|
||||
center: [13.4, 52.5],
|
||||
zoom: 18,
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
try {
|
||||
// Make sure quick search buttons setting is enabled
|
||||
const settings = this.owner.lookup('service:settings');
|
||||
settings.showQuickSearchButtons = true;
|
||||
|
||||
// 1. Visit slowly loading category search
|
||||
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Verify loading state is set and pills are visible (i.e. header-center does NOT have .searching)
|
||||
assert.ok(mapUi.loadingState, 'Search is loading');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.doesNotHaveClass(
|
||||
'searching',
|
||||
'Pills remain visible while search is loading'
|
||||
);
|
||||
|
||||
// Resolve the promise with empty results
|
||||
osmResolve();
|
||||
await catPromise;
|
||||
await settled();
|
||||
|
||||
// Verify search completed and since results are empty, pills are still visible
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'No results found');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.doesNotHaveClass(
|
||||
'searching',
|
||||
'Pills remain visible after search completes with no results'
|
||||
);
|
||||
|
||||
// 2. Now simulate a fast category search that returns results
|
||||
const osmService = this.owner.lookup('service:osm');
|
||||
osmService.getCategoryPois = async () => [
|
||||
{
|
||||
title: 'Latte Art Cafe',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '101',
|
||||
osmType: 'N',
|
||||
},
|
||||
];
|
||||
|
||||
await visit('/search?category=coffee&lat=1&lon=1');
|
||||
|
||||
// Verify search completed with results, so pills are hidden
|
||||
assert.ok(mapUi.searchResults.length > 0, 'Results found');
|
||||
assert
|
||||
.dom('.header-center')
|
||||
.hasClass(
|
||||
'searching',
|
||||
'Pills are hidden after search completes with results'
|
||||
);
|
||||
} finally {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,4 +270,76 @@ module('Acceptance | search', function (hooks) {
|
||||
.hasText('Search request failed. Please try again.');
|
||||
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
|
||||
});
|
||||
|
||||
test('search box query synchronized with active route query parameters', async function (assert) {
|
||||
// Mock Osm Service
|
||||
class MockOsmService extends Service {
|
||||
async getCategoryPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Storage Service
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Map Service
|
||||
class MockMapService extends Service {
|
||||
getBounds() {
|
||||
return {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.6,
|
||||
maxLon: 13.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
// 1. Visit a search URL directly
|
||||
await visit('/search?q=Berlin');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue(
|
||||
'Berlin',
|
||||
'Search input is populated with search term on direct load'
|
||||
);
|
||||
|
||||
// 2. Visit a category search URL
|
||||
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue(
|
||||
'Coffee',
|
||||
'Search input is populated with mapped category label'
|
||||
);
|
||||
|
||||
// 3. Go back to index
|
||||
await visit('/');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('', 'Search input is cleared on transitioning to index');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,93 @@ import {
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
import { setupLocalForageMock } from './mock-local-forage';
|
||||
import sinon from 'sinon';
|
||||
|
||||
function setupMapStyleMocks(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
// Stub window.fetch to capture map-style assets before they hit the network
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (typeof url === 'string' && url.includes('tiles.openfreemap.org')) {
|
||||
// A. Mock Style Sheet
|
||||
if (url.includes('/styles/liberty')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Mock Style',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
tiles: [], // Empty tiles list prevents any map tile fetching completely!
|
||||
},
|
||||
},
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f9fa' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// B. Mock Sprite Atlas JSON
|
||||
if (url.endsWith('.json') && url.includes('/sprites/')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}), // Empty sprite dictionary
|
||||
};
|
||||
}
|
||||
|
||||
// C. Mock Sprite Atlas PNG (Returns a valid 1x1 transparent PNG)
|
||||
if (url.endsWith('.png') && url.includes('/sprites/')) {
|
||||
const bytes = new Uint8Array([
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73,
|
||||
68, 65, 84, 120, 156, 99, 96, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0,
|
||||
0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
]);
|
||||
const blob = new Blob([bytes], { type: 'image/png' });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}
|
||||
|
||||
// Catch-all mock for other openfreemap endpoints
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to original fetch (e.g. Photon results, local mock APIs)
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
if (this.fetchStub && typeof this.fetchStub.restore === 'function') {
|
||||
this.fetchStub.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This file exists to provide wrappers around ember-qunit's
|
||||
// test setup functions. This way, you can easily extend the setup that is
|
||||
@@ -12,6 +99,8 @@ import { setupNostrMocks } from './mock-nostr';
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
@@ -32,6 +121,7 @@ function setupApplicationTest(hooks, options) {
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
@@ -39,6 +129,7 @@ function setupRenderingTest(hooks, options) {
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Service from '@ember/service';
|
||||
|
||||
/**
|
||||
* In-memory mock of the `localForage` service for tests. Uses per-store
|
||||
* `Map`s so tests stay isolated from real IndexedDB state and stay
|
||||
* deterministic.
|
||||
*
|
||||
* Mirrors the real service's interface: `get/set/remove/keys/clear/iterate`.
|
||||
*/
|
||||
export class MockLocalForageService extends Service {
|
||||
_stores = new Map();
|
||||
|
||||
_store(name) {
|
||||
let store = this._stores.get(name);
|
||||
if (!store) {
|
||||
store = new Map();
|
||||
this._stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
const store = this._store(storeName);
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
}
|
||||
|
||||
async set(storeName, key, value) {
|
||||
this._store(storeName).set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async remove(storeName, key) {
|
||||
this._store(storeName).delete(key);
|
||||
}
|
||||
|
||||
async keys(storeName) {
|
||||
return [...this._store(storeName).keys()];
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
this._store(storeName).clear();
|
||||
}
|
||||
|
||||
async iterate(storeName, fn) {
|
||||
let result;
|
||||
let idx = 0;
|
||||
for (const [key, value] of this._store(storeName)) {
|
||||
result = fn(value, key, idx);
|
||||
idx++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupLocalForageMock(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:localForage', MockLocalForageService);
|
||||
});
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user