Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a19e30b8
|
No files matched your search
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
version: 10
|
||||
- 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: 11
|
||||
version: 10
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -9,7 +9,6 @@ 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;
|
||||
@@ -17,59 +16,11 @@ export default class AppHeaderComponent extends Component {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked 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 hasQuery() {
|
||||
return !!this.searchQuery;
|
||||
}
|
||||
|
||||
get showQuickSearch() {
|
||||
@@ -110,7 +61,7 @@ export default class AppHeaderComponent extends Component {
|
||||
</div>
|
||||
|
||||
{{#if this.showQuickSearch}}
|
||||
<div class="header-center {{if this.isSearching 'searching'}}">
|
||||
<div class="header-center {{if this.hasQuery '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 has-back-btn">
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">About</h2>
|
||||
<h2>About</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
@@ -34,15 +34,6 @@ 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>
|
||||
@@ -100,26 +91,6 @@ 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
|
||||
@@ -168,20 +139,13 @@ 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,18 +19,6 @@ 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,7 +2,6 @@ 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';
|
||||
@@ -10,7 +9,6 @@ import AppMenuSettings from './settings';
|
||||
import AppMenuAbout from './about';
|
||||
|
||||
export default class AppMenu extends Component {
|
||||
@service router;
|
||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||
|
||||
@action
|
||||
@@ -18,25 +16,10 @@ 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}}
|
||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||
@onContributions={{this.goToContributions}}
|
||||
/>
|
||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
<AppMenuSettings
|
||||
|
||||
@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">Settings</h2>
|
||||
<h2>Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
|
||||
@@ -5,11 +5,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
} from '../../../utils/nostr';
|
||||
import { normalizeRelayUrl } from '../../../utils/nostr';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
@@ -21,74 +17,6 @@ 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;
|
||||
@@ -104,51 +32,19 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
const url = normalizeRelayUrl(this.newReadRelay);
|
||||
if (!url) return;
|
||||
|
||||
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
|
||||
);
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrReadRelays', Array.from(set));
|
||||
this.newReadRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeReadRelay(url) {
|
||||
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
|
||||
);
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrReadRelays', filtered);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -168,7 +64,6 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
@action
|
||||
resetReadRelays() {
|
||||
this.settings.update('nostrReadRelays', null);
|
||||
this.settings.update('nostrReadRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -176,57 +71,24 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
if (!url) return;
|
||||
|
||||
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
|
||||
);
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrWriteRelays', Array.from(set));
|
||||
this.newWriteRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeWriteRelay(url) {
|
||||
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
|
||||
);
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrWriteRelays', filtered);
|
||||
}
|
||||
|
||||
@action
|
||||
resetWriteRelays() {
|
||||
this.settings.update('nostrWriteRelays', null);
|
||||
this.settings.update('nostrWriteRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -250,20 +112,18 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
{{#each this.nostrData.activeReadRelays 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}}
|
||||
<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>
|
||||
@@ -283,7 +143,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasReadOverrides}}
|
||||
{{#if this.settings.nostrReadRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
@@ -297,20 +157,18 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
{{#each this.nostrData.activeWriteRelays 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}}
|
||||
<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>
|
||||
@@ -330,7 +188,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
{{#if this.settings.nostrWriteRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
}
|
||||
+22
-31
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -21,6 +20,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIconNameForTags } from '../utils/osm-icons';
|
||||
import config from 'marco/config/environment';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@@ -287,9 +287,26 @@ export default class MapComponent extends Component {
|
||||
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,',
|
||||
});
|
||||
if (config.environment === 'test') {
|
||||
apply(this.mapInstance, {
|
||||
version: 8,
|
||||
name: 'Test Style',
|
||||
sources: {},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#f8f4f0',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
});
|
||||
}
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
this.searchOverlayElement.className = 'search-pulse';
|
||||
@@ -666,12 +683,7 @@ 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.
|
||||
// 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 });
|
||||
}
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
@@ -684,20 +696,6 @@ 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;
|
||||
|
||||
@@ -1108,7 +1106,6 @@ 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);
|
||||
|
||||
@@ -1212,12 +1209,6 @@ 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();
|
||||
|
||||
@@ -237,7 +237,7 @@ export default class PhotoCarousel extends Component {
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{photo.alt}}
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
{{else if this.isGalleryThumbnails}}
|
||||
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||
alt={{photo.alt}}
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{else}}
|
||||
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{photo.alt}}
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{photo.alt}}
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { EventFactory } from 'applesauce-factory';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
@@ -201,17 +201,21 @@ export default class PhotoGallery extends Component {
|
||||
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 factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
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();
|
||||
const template = {
|
||||
kind: 5,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: 'Deleted photo',
|
||||
tags,
|
||||
};
|
||||
|
||||
const event = await factory.sign(template);
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
|
||||
@@ -23,7 +23,6 @@ export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
@@ -232,7 +231,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>')
|
||||
@@ -346,21 +345,9 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
get osmUrl() {
|
||||
const id = this.place.osmId;
|
||||
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)}`;
|
||||
if (!id) return null;
|
||||
const type = this.place.osmType || 'node';
|
||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||
}
|
||||
|
||||
get gmapsUrl() {
|
||||
@@ -604,7 +591,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
</div>
|
||||
|
||||
{{#if this.place.osmId}}
|
||||
{{#if this.osmUrl}}
|
||||
<div class="meta-info">
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="feather-camera" />
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
|
||||
checked={{this.isSaved}}
|
||||
{{on "change" this.toggleSaved}}
|
||||
/>
|
||||
<span class="list-color-dot"></span>
|
||||
<span class="list-color"></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-dot"
|
||||
class="list-color"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<span class="list-name">{{list.title}}</span>
|
||||
|
||||
@@ -27,7 +27,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
@tracked isPublishing = false;
|
||||
@tracked isDragging = false;
|
||||
@tracked selectedTags = [];
|
||||
@tracked altText = '';
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -104,7 +103,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
this.selectedTags = [];
|
||||
this.altText = '';
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
}
|
||||
@@ -120,11 +118,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
this.selectedTags = [tag];
|
||||
}
|
||||
|
||||
@action
|
||||
updateAltText(event) {
|
||||
this.altText = event.target.value;
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (photoData.hash) {
|
||||
@@ -162,6 +155,8 @@ 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) {
|
||||
@@ -184,10 +179,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
imeta.push(`dim ${photo.dim}`);
|
||||
}
|
||||
|
||||
const alt = this.altText.trim();
|
||||
if (alt) {
|
||||
imeta.push(`alt ${alt}`);
|
||||
}
|
||||
imeta.push('alt A photo of a place');
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
@@ -206,11 +198,17 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags.push(imeta);
|
||||
|
||||
// NIP-XX draft Place Photo event
|
||||
const event = await EventFactory.fromKind(360)
|
||||
.content('')
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
const template = {
|
||||
kind: 360,
|
||||
content: '',
|
||||
tags,
|
||||
};
|
||||
|
||||
if (!template.created_at) {
|
||||
template.created_at = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
const event = await factory.sign(template);
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -219,7 +217,6 @@ 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);
|
||||
@@ -246,11 +243,13 @@ export default class PlacePhotoUpload extends Component {
|
||||
{{/if}}
|
||||
|
||||
{{#if this.file}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
<div class="photo-grid">
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.suggestedTags.length}}
|
||||
<div class="photo-tag-suggestions">
|
||||
@@ -272,19 +271,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-publish"
|
||||
|
||||
@@ -5,13 +5,10 @@ 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;
|
||||
@@ -163,11 +160,7 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div
|
||||
class="sidebar-header
|
||||
{{if this.hasHeaderPhoto 'no-border'}}
|
||||
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
|
||||
>
|
||||
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||
{{#if @selectedPlace}}
|
||||
<button
|
||||
type="button"
|
||||
@@ -175,32 +168,11 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
{{#if @onBack}}
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
|
||||
@name="arrow-left"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
{{/if}}
|
||||
{{#if @onBack}}
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@size={{20}}
|
||||
@color={{or @color "#898989"}}
|
||||
/>
|
||||
</span>
|
||||
{{@title}}
|
||||
</h2>
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{else}}
|
||||
{{#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}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@@ -210,7 +182,7 @@ export default class PlacesSidebar extends Component {
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||
<div class="sidebar-content">
|
||||
{{#if @selectedPlace}}
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@@ -218,58 +190,54 @@ export default class PlacesSidebar extends Component {
|
||||
@onSave={{this.updateBookmark}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @isLoading}}
|
||||
<div class="sidebar-loading">
|
||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||
</div>
|
||||
{{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")}}
|
||||
{{#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}}
|
||||
{{else if (getPlaceType place.osmTags)}}
|
||||
{{getPlaceType place.osmTags}}
|
||||
{{else}}
|
||||
Saved place
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/if}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/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>
|
||||
{{/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>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -20,26 +20,7 @@ export default class SearchController extends Controller {
|
||||
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
|
||||
// Hide sidebar and clear previous results immediately to signal a new search
|
||||
this.mapUi.hideSidebar();
|
||||
this.mapUi.clearSearchResults();
|
||||
|
||||
@@ -179,8 +160,10 @@ export default class SearchController extends Controller {
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
let matchedPlace = pois.find(
|
||||
matchedPlace = pois.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import { formatRelativeDate as format } from '../utils/format-text';
|
||||
|
||||
export default helper(function formatRelativeDate([timestamp]) {
|
||||
return format(timestamp);
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [scrollTop]) => {
|
||||
if (element && typeof scrollTop === 'number' && scrollTop > 0) {
|
||||
// Restore inside requestAnimationFrame to guarantee layout rendering is ready
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,11 +10,6 @@ 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' });
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,6 +6,5 @@ export default class IndexRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.clearSearchResults();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ListsIndexRoute extends Route {}
|
||||
@@ -1,26 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
model(params) {
|
||||
// Resolve instantly so transition happens in 0ms!
|
||||
return { list_id: params.list_id };
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
console.debug('DEBUG: setupController controller is:', controller);
|
||||
console.debug(
|
||||
'DEBUG: controller.loadPlacesTask is:',
|
||||
controller?.loadPlacesTask
|
||||
);
|
||||
controller.model = model;
|
||||
super.setupController(controller, model);
|
||||
if (controller && controller.loadPlacesTask) {
|
||||
controller.loadPlacesTask.perform(model.list_id);
|
||||
} else {
|
||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class MenuRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
}
|
||||
+4
-40
@@ -1,23 +1,6 @@
|
||||
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;
|
||||
@@ -125,7 +108,6 @@ 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) {
|
||||
@@ -148,32 +130,14 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
if (!place || !place.osmId || !place.osmType) return;
|
||||
|
||||
// Bookmarked place — refresh via storage, which persists the update.
|
||||
if (place.id) {
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -35,20 +35,23 @@ 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 authEvent = await EventFactory.fromKind(24242)
|
||||
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
|
||||
.modifyPublicTags(() => [
|
||||
const authTemplate = {
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||
tags: [
|
||||
['t', action],
|
||||
['x', hash],
|
||||
['expiration', String(now + 3600)],
|
||||
['server', serverHostname],
|
||||
])
|
||||
.created(now)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
],
|
||||
};
|
||||
|
||||
const authEvent = await factory.sign(authTemplate);
|
||||
const base64 = btoa(JSON.stringify(authEvent));
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
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}`, { cause: e });
|
||||
throw new Error(`Failed to process image: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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
-23
@@ -1,6 +1,5 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@service nostrData;
|
||||
@@ -10,7 +9,6 @@ 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;
|
||||
@@ -21,33 +19,13 @@ export default class MapUiService extends Service {
|
||||
@tracked currentSearch = null;
|
||||
@tracked loadingState = null;
|
||||
@tracked isSidebarVisible = false;
|
||||
@tracked isSidebarOpening = false;
|
||||
|
||||
scrollPositions = {};
|
||||
|
||||
@action
|
||||
saveScrollPosition(key, value) {
|
||||
this.scrollPositions[key] = value;
|
||||
}
|
||||
|
||||
@action
|
||||
getScrollPosition(key) {
|
||||
return this.scrollPositions[key] || 0;
|
||||
}
|
||||
|
||||
showSidebar() {
|
||||
if (!this.isSidebarVisible) {
|
||||
this.isSidebarVisible = true;
|
||||
this.isSidebarOpening = true;
|
||||
setTimeout(() => {
|
||||
this.isSidebarOpening = false;
|
||||
}, 250);
|
||||
}
|
||||
this.isSidebarVisible = true;
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
this.isSidebarVisible = false;
|
||||
this.isSidebarOpening = false;
|
||||
}
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
|
||||
@@ -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://nostr.kosmos.org';
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||
|
||||
import { isMobile } from '../utils/device';
|
||||
|
||||
@@ -150,6 +150,9 @@ 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],
|
||||
@@ -232,6 +235,9 @@ 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],
|
||||
|
||||
+26
-107
@@ -6,12 +6,7 @@ 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 {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
uniqNormalizedRelays,
|
||||
} from '../utils/nostr';
|
||||
import { normalizeRelayUrl } from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
|
||||
const DIRECTORY_RELAYS = [
|
||||
@@ -34,13 +29,11 @@ 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;
|
||||
@@ -90,62 +83,43 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
get requiredReadRelays() {
|
||||
return DEFAULT_READ_RELAYS;
|
||||
}
|
||||
|
||||
get requiredWriteRelays() {
|
||||
return DEFAULT_WRITE_RELAYS;
|
||||
}
|
||||
|
||||
get mailboxReadRelays() {
|
||||
return (this.mailboxes?.inboxes || [])
|
||||
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 mailboxWriteRelays() {
|
||||
return (this.mailboxes?.outboxes || [])
|
||||
get defaultWriteRelays() {
|
||||
const mailboxes = (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 || []
|
||||
);
|
||||
const defaults =
|
||||
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredReadRelays,
|
||||
this.configuredReadRelays
|
||||
);
|
||||
if (this.settings.nostrReadRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultReadRelays;
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredWriteRelays,
|
||||
this.configuredWriteRelays
|
||||
);
|
||||
if (this.settings.nostrWriteRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultWriteRelays;
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
@@ -280,57 +254,6 @@ 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;
|
||||
|
||||
@@ -462,10 +385,6 @@ export default class NostrDataService extends Service {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
@@ -45,9 +45,6 @@ export default class OsmAuthService extends Service {
|
||||
clientId: clientId,
|
||||
redirectUrl: redirectUrl,
|
||||
storeRefreshToken: true,
|
||||
onAccessTokenExpiry() {
|
||||
return this.exchangeRefreshTokenForAccessToken();
|
||||
},
|
||||
},
|
||||
new MarcoOsmAuthStorage()
|
||||
);
|
||||
|
||||
+20
-230
@@ -4,110 +4,12 @@ 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();
|
||||
@@ -328,57 +230,16 @@ out center;
|
||||
return this.normalizePoi(data.elements[0]);
|
||||
}
|
||||
|
||||
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
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 cacheKey = `${osmType}:${osmId}`;
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||
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`;
|
||||
@@ -402,100 +263,29 @@ out center;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
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;
|
||||
} 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,8 +9,6 @@ const DEFAULT_SETTINGS = {
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrReadRelays: null,
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
@@ -23,9 +21,6 @@ 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;
|
||||
|
||||
@@ -116,8 +111,6 @@ 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;
|
||||
|
||||
@@ -134,8 +127,6 @@ export default class SettingsService extends Service {
|
||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||
nostrReadRelays: this.nostrReadRelays,
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
@@ -270,11 +270,6 @@ export default class StorageService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
async getPlacesInList(listId) {
|
||||
if (!this.places || !this.places.lists) return [];
|
||||
return this.places.lists.getPlaces(listId);
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
// 1. Calculate required prefixes
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
@@ -426,8 +421,7 @@ 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,
|
||||
{ forceFresh: true }
|
||||
place.osmType
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
|
||||
+49
-301
@@ -1,10 +1,8 @@
|
||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||
|
||||
:root {
|
||||
--body-text-color: #333;
|
||||
--primary-background-color: #fff;
|
||||
--default-list-color: #fc3;
|
||||
--hover-bg: #f8f9fa;
|
||||
--divider-color: #eee;
|
||||
--sidebar-width: 350px;
|
||||
--link-color: #2a7fff;
|
||||
--link-color-visited: #6a4fbf;
|
||||
@@ -12,7 +10,6 @@
|
||||
--marker-color-dark: #b31412;
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -34,7 +31,7 @@ body {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -115,6 +112,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-area: search;
|
||||
|
||||
/* Ensure it sits at the start of its grid area */
|
||||
justify-self: start;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -128,6 +127,7 @@ 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,6 +140,8 @@ 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 */
|
||||
@@ -154,6 +156,7 @@ 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;
|
||||
@@ -245,6 +248,13 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
@@ -252,7 +262,6 @@ body {
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
width: 100%;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.photo-upload-item img {
|
||||
@@ -374,7 +383,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.account-item:last-child {
|
||||
@@ -451,11 +460,6 @@ 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 {
|
||||
@@ -474,13 +478,11 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 56px; /* Strictly enforce identical vertical height */
|
||||
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box; /* Guarantee strict height boundaries */
|
||||
}
|
||||
|
||||
.sidebar-header.no-border {
|
||||
@@ -489,7 +491,7 @@ body {
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -524,7 +526,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
@@ -555,7 +557,7 @@ body {
|
||||
padding-left: 1.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
@@ -626,7 +628,7 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-background-color);
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
@@ -642,7 +644,7 @@ body {
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
background-color: var(--danger-color);
|
||||
color: var(--primary-background-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
@@ -672,7 +674,7 @@ body {
|
||||
margin-bottom: 1rem;
|
||||
background: var(--hover-bg);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -694,8 +696,8 @@ body {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||
color: var(--body-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
@@ -706,7 +708,7 @@ body {
|
||||
|
||||
select.form-control {
|
||||
appearance: none;
|
||||
background-color: var(--primary-background-color);
|
||||
background-color: #fff;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
@@ -752,25 +754,6 @@ 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%;
|
||||
}
|
||||
@@ -801,7 +784,7 @@ select.form-control {
|
||||
.meta-info p:first-child {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.meta-info a,
|
||||
@@ -860,9 +843,9 @@ abbr[title] {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
@@ -1033,7 +1016,7 @@ abbr[title] {
|
||||
.photo-carousel.inline .photo-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: var(--primary-background-color);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide {
|
||||
@@ -1112,7 +1095,7 @@ abbr[title] {
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1121,7 +1104,7 @@ abbr[title] {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
border: 1px solid rgb(255 204 51 / 20%);
|
||||
background: rgb(255 204 51 / 30%);
|
||||
}
|
||||
@@ -1283,20 +1266,6 @@ 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;
|
||||
}
|
||||
@@ -1378,7 +1347,7 @@ span.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
@@ -1439,11 +1408,6 @@ 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;
|
||||
|
||||
@@ -1517,7 +1481,7 @@ button.create-place {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
@@ -1543,11 +1507,12 @@ 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: var(--body-text-color);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
@@ -1565,7 +1530,7 @@ button.create-place {
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Search Results Popover */
|
||||
@@ -1634,7 +1599,7 @@ button.create-place {
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1686,7 +1651,7 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.place-lists-manager input[type='checkbox'] {
|
||||
@@ -1696,8 +1661,7 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shared List Color Dot */
|
||||
.list-color-dot {
|
||||
.place-lists-manager .list-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--default-list-color);
|
||||
@@ -1708,7 +1672,7 @@ button.create-place {
|
||||
|
||||
.place-lists-manager .divider {
|
||||
height: 1px;
|
||||
background: var(--divider-color);
|
||||
background: #eee;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1751,7 +1715,7 @@ button.create-place {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px; /* Pill shape */
|
||||
font-size: 0.9rem;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||
@@ -1763,7 +1727,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.category-chip:active {
|
||||
background: var(--divider-color);
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
@@ -1879,12 +1843,8 @@ button.create-place {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.place-photo-upload .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions {
|
||||
margin-bottom: 1.25rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.photo-tag-suggestions-title {
|
||||
@@ -1902,7 +1862,7 @@ button.create-place {
|
||||
|
||||
.photo-tag-chip {
|
||||
background: #f8f9fa;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
@@ -1912,7 +1872,7 @@ button.create-place {
|
||||
}
|
||||
|
||||
.photo-tag-chip:hover {
|
||||
background: var(--divider-color);
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.photo-tag-chip.is-selected {
|
||||
@@ -2143,7 +2103,7 @@ button.create-place {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--body-text-color);
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -2163,215 +2123,3 @@ button.create-place {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Snappy slide-in from left (Desktop) */
|
||||
@keyframes sidebar-slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Snappy slide-up from bottom (Mobile) */
|
||||
@keyframes sidebar-slide-up-bottom {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists Index Sidebar Menu */
|
||||
.lists-index-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lists-index-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.lists-index-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lists-index-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lists-index-count {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Centered layout when back button is present */
|
||||
.sidebar-header.has-back-btn {
|
||||
position: relative;
|
||||
justify-content: center; /* Center horizontally */
|
||||
}
|
||||
|
||||
/* Absolute positioning for buttons in centered header */
|
||||
.sidebar-header.has-back-btn .back-btn {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header.has-back-btn .close-btn {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Centralized Title text */
|
||||
.sidebar-header-text-centered {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.sidebar-header-icon-wrapper {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
margin-right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 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,9 +2,12 @@ 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 {
|
||||
@@ -12,18 +15,16 @@ 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, menu, lists, contributions or place routes AND it's visible.
|
||||
// We consider the sidebar "open" if we are in search or place routes AND it's visible.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
const name = this.router.currentRouteName;
|
||||
return (
|
||||
this.mapUi.isSidebarVisible &&
|
||||
(name === 'place' ||
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists'))
|
||||
(this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,40 +37,24 @@ export default class ApplicationComponent extends Component {
|
||||
|
||||
@action
|
||||
toggleAppMenu() {
|
||||
if (this.router.currentRouteName === 'menu') {
|
||||
this.router.transitionTo('index');
|
||||
} else {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
closeAppMenu() {
|
||||
this.isAppMenuOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
const name = this.router.currentRouteName;
|
||||
if (
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists')
|
||||
if (this.isAppMenuOpen) {
|
||||
this.closeAppMenu();
|
||||
} else if (
|
||||
this.router.currentRouteName === 'search' ||
|
||||
this.router.currentRouteName === 'place'
|
||||
) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,30 +66,32 @@ export default class ApplicationComponent extends Component {
|
||||
<template>
|
||||
{{pageTitle "Marco"}}
|
||||
|
||||
<div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
|
||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
></div>
|
||||
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
id="rs-widget-container"
|
||||
class={{if this.storage.isWidgetOpen "visible"}}
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.storage.isWidgetOpen}}
|
||||
<div
|
||||
class="rs-backdrop"
|
||||
role="button"
|
||||
{{on "click" this.storage.closeWidget}}
|
||||
></div>
|
||||
{{/if}}
|
||||
<Map
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
|
||||
<Map
|
||||
@isSidebarOpen={{this.isSidebarOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
{{#if this.isAppMenuOpen}}
|
||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||
{{/if}}
|
||||
|
||||
<Toast />
|
||||
<Toast />
|
||||
|
||||
{{outlet}}
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
@@ -1 +0,0 @@
|
||||
<template>{{outlet}}</template>
|
||||
@@ -1,89 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
styleFor(color) {
|
||||
const finalColor =
|
||||
color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim();
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header has-back-btn">
|
||||
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
Collections
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="places-list">
|
||||
{{#each this.storage.lists as |list|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="lists-index-item"
|
||||
{{on "click" (fn this.selectList list.id)}}
|
||||
>
|
||||
<div class="lists-index-item-left">
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<span
|
||||
class="list-color-dot"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<div class="lists-index-name">{{list.title}}</div>
|
||||
</div>
|
||||
<div class="lists-index-count">
|
||||
{{#if list.placeRefs.length}}
|
||||
{{list.placeRefs.length}}
|
||||
places
|
||||
{{else}}
|
||||
empty
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
|
||||
<template>
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{@controller.places}}
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class MenuTemplate extends Component {
|
||||
@service router;
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
<template><AppMenu @onClose={{this.close}} /></template>
|
||||
}
|
||||
+1
-18
@@ -77,18 +77,8 @@ 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)
|
||||
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
@@ -108,13 +98,6 @@ export default class PlaceTemplate extends Component {
|
||||
close() {
|
||||
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>
|
||||
|
||||
@@ -10,10 +10,6 @@ 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;
|
||||
@@ -32,7 +28,6 @@ export default class SearchTemplate extends Component {
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.mapUi.searchResults}}
|
||||
@scrollTop={{this.mapUi.getScrollPosition "search"}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -12,23 +12,3 @@ 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',
|
||||
});
|
||||
}
|
||||
+2
-29
@@ -1,7 +1,4 @@
|
||||
// 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';
|
||||
@@ -43,9 +40,6 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||
import 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';
|
||||
@@ -59,12 +53,8 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import 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';
|
||||
@@ -92,17 +82,12 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.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';
|
||||
@@ -118,7 +103,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/open_scissors.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.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';
|
||||
@@ -133,9 +118,6 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import 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';
|
||||
@@ -162,13 +144,11 @@ 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,
|
||||
@@ -205,7 +185,6 @@ const ICONS = {
|
||||
'ice-cream-on-cone': iceCreamOnCone,
|
||||
'industrial-building': industrialBuilding,
|
||||
info,
|
||||
'info-i': infoI,
|
||||
instagram,
|
||||
jewel,
|
||||
'log-in': logIn,
|
||||
@@ -214,11 +193,7 @@ const ICONS = {
|
||||
mail,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
'market-stall': marketStall,
|
||||
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||
menu,
|
||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||
'molar-tooth': molarTooth,
|
||||
'more-horizontal': moreHorizontal,
|
||||
'more-vertical': moreVertical,
|
||||
navigation,
|
||||
@@ -270,8 +245,6 @@ const ICONS = {
|
||||
winding_way_wide: windingWayWide,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
'car-and-wrench': carAndWrench,
|
||||
'castle-keep': castleKeep,
|
||||
x,
|
||||
zap,
|
||||
'loading-ring': loadingRing,
|
||||
|
||||
@@ -14,30 +14,6 @@ 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.
|
||||
@@ -67,7 +43,6 @@ 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)) {
|
||||
@@ -86,10 +61,6 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +76,6 @@ export function parsePlacePhotos(events) {
|
||||
aspectRatio,
|
||||
placeIdentifier,
|
||||
tags: eventTagValues,
|
||||
alt: altText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ 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' },
|
||||
@@ -74,7 +72,6 @@ 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' },
|
||||
@@ -106,7 +103,6 @@ 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' },
|
||||
@@ -117,9 +113,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||
|
||||
+1
-10
@@ -56,24 +56,15 @@ 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') {
|
||||
if (!fallbackKey) {
|
||||
fallbackKey = key;
|
||||
}
|
||||
continue;
|
||||
return humanizeOsmTag(key);
|
||||
}
|
||||
return humanizeOsmTag(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackKey) {
|
||||
return humanizeOsmTag(fallbackKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
|
||||
label: 'Things to do',
|
||||
icon: 'feather-camera',
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||
'["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|guest_house)$"]'],
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -37,6 +37,8 @@ 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';
|
||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
||||
|
||||
const esmParserOptions = {
|
||||
ecmaFeatures: { modules: true },
|
||||
|
||||
+55
-55
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.27.1",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -39,61 +39,61 @@
|
||||
"version": "pnpm build && git add release/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||
"@embroider/macros": "^1.20.6",
|
||||
"@embroider/macros": "^1.19.6",
|
||||
"@embroider/router": "^3.0.6",
|
||||
"@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",
|
||||
"@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",
|
||||
"ember-page-title": "^9.0.3",
|
||||
"ember-qunit": "^9.1.0",
|
||||
"ember-resolver": "^13.2.0",
|
||||
"ember-source": "~7.2.0",
|
||||
"ember-qunit": "^9.0.4",
|
||||
"ember-resolver": "^13.1.1",
|
||||
"ember-source": "~6.11.0-alpha.6",
|
||||
"ember-template-lint": "^7.9.3",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"ember-welcome-page": "^8.0.4",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"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",
|
||||
"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",
|
||||
"feather-icons": "^4.29.2",
|
||||
"globals": "^17.11.0",
|
||||
"globals": "^16.5.0",
|
||||
"latlon-geohash": "^2.0.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",
|
||||
"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",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"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"
|
||||
"remotestoragejs": "2.0.0-beta.9",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"testem": "^3.17.0",
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -102,17 +102,17 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"blurhash": "^2.0.5",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.1.0",
|
||||
"localforage": "^1.10.0",
|
||||
"nostr-idb": "^5.1.0",
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"nostr-idb": "^5.0.0",
|
||||
"oauth2-pkce": "^2.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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
+3173
-4328
File diff suppressed because it is too large.
Load diff
@@ -1,5 +0,0 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
patchedDependencies:
|
||||
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
|
||||
@@ -0,0 +1 @@
|
||||
!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})}}}();
|
||||
@@ -1,182 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
const amdModule0=Object.freeze(Object.defineProperty({__proto__:null,get default(){return App}},Symbol.toStringTag,{value:"Module"}));!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))t(e);new MutationObserver(e=>{for(const r of e)if("childList"===r.type)for(const e of r.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&t(e)}).observe(document,{childList:!0,subtree:!0})}function t(e){if(e.ep)return;e.ep=!0;const t=function(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?t.credentials="include":"anonymous"===e.crossOrigin?t.credentials="omit":t.credentials="same-origin",t}(e);fetch(e.href,t)}}();const hasDOM="object"==typeof self&&null!==self&&self.Object===Object&&"undefined"!=typeof Window&&self.constructor===Window&&"object"==typeof document&&null!==document&&self.document===document&&"object"==typeof location&&null!==location&&self.location===location&&"object"==typeof history&&null!==history&&self.history===history&&"object"==typeof navigator&&null!==navigator&&self.navigator===navigator&&"string"==typeof navigator.userAgent,window$1=hasDOM?self:null,location$1=hasDOM?self.location:null,history$1=hasDOM?self.history:null,userAgent=hasDOM?self.navigator.userAgent:"Lynx (textmode)",isChrome=!(!hasDOM||"object"!=typeof chrome||"object"==typeof opera),isFirefox=!!hasDOM&&/Firefox|FxiOS/.test(userAgent),environment=Object.freeze(Object.defineProperty({__proto__:null,hasDOM:hasDOM,history:history$1,isChrome:isChrome,isFirefox:isFirefox,location:location$1,userAgent:userAgent,window:window$1},Symbol.toStringTag,{value:"Module"}));function checkGlobal(e){return e&&e.Object===Object?e:void 0}function checkElementIdShadowing(e){return e&&void 0===e.nodeType?e:void 0}const global$1=checkGlobal(checkElementIdShadowing("object"==typeof global&&global))||checkGlobal("object"==typeof self&&self)||checkGlobal("object"==typeof window&&window)||"undefined"!=typeof mainContext&&mainContext||new Function("return this")(),context$1=function(e,t){return void 0===t?{imports:e,exports:e,lookup:e}:{imports:t.imports||e,exports:t.exports||e,lookup:t.lookup||e}}(global$1,global$1.Ember);function getLookup(){return context$1.lookup}function setLookup(e){context$1.lookup=e}const ENV$1={ENABLE_OPTIONAL_FEATURES:!1,EXTEND_PROTOTYPES:{Array:!1},LOG_STACKTRACE_ON_DEPRECATION:!0,LOG_VERSION:!0,RAISE_ON_DEPRECATION:!1,STRUCTURED_PROFILE:!1,_DEBUG_RENDER_TREE:!1,_ALL_DEPRECATIONS_ENABLED:!1,_OVERRIDE_DEPRECATION_VERSION:null,_DEFAULT_ASYNC_OBSERVERS:!1,_RERENDER_LOOP_LIMIT:1e3,EMBER_LOAD_HOOKS:{},FEATURES:{}};function getENV(){return ENV$1}(e=>{if("object"!=typeof e||null===e)return;for(let n in e){if(!Object.prototype.hasOwnProperty.call(e,n)||"EXTEND_PROTOTYPES"===n||"EMBER_LOAD_HOOKS"===n)continue;let t=ENV$1[n];ENV$1[n]=!0===t?!1!==e[n]:!1===t?!0===e[n]:e[n]}let{EMBER_LOAD_HOOKS:t}=e;if("object"==typeof t&&null!==t)for(let n in t){if(!Object.prototype.hasOwnProperty.call(t,n))continue;let e=t[n];Array.isArray(e)&&(ENV$1.EMBER_LOAD_HOOKS[n]=e.filter(e=>"function"==typeof e))}let{FEATURES:r}=e;if("object"==typeof r&&null!==r)for(let n in r)Object.prototype.hasOwnProperty.call(r,n)&&(ENV$1.FEATURES[n]=!0===r[n])})(global$1.EmberENV);let assert$3=()=>{},registerHandler$1=()=>{},deprecate$1=()=>{};const defaultDeprecate=deprecate$1;let testing=!1;function isTesting(){return testing}function setTesting(e){testing=Boolean(e)}let registerHandler=()=>{};const{toString:objectToString$1}=Object.prototype,{toString:functionToString}=Function.prototype,{isArray:isArray$6}=Array,{keys:objectKeys}=Object,{stringify:stringify}=JSON,LIST_LIMIT=100,DEPTH_LIMIT=4,SAFE_KEY=/^[\w$]+$/;function inspect(e){return"number"==typeof e&&2===arguments.length?this:inspectValue(e,0)}function inspectValue(e,t,r){let n=!1;switch(typeof e){case"undefined":return"undefined";case"object":if(null===e)return"null";if(isArray$6(e)){n=!0;break}if(e.toString===objectToString$1||void 0===e.toString)break;return e.toString();case"function":return e.toString===functionToString?e.name?`[Function:${e.name}]`:"[Function]":e.toString();case"string":return stringify(e);default:return e.toString()}if(void 0===r)r=new WeakSet;else if(r.has(e))return"[Circular]";return r.add(e),n?inspectArray(e,t+1,r):inspectObject(e,t+1,r)}function inspectKey(e){return SAFE_KEY.test(e)?e:stringify(e)}function inspectObject(e,t,r){if(t>DEPTH_LIMIT)return"[Object]";let n="{",i=objectKeys(e);for(let o=0;o<i.length;o++){if(n+=0===o?" ":", ",o>=LIST_LIMIT){n+=`... ${i.length-LIST_LIMIT} more keys`;break}let s=i[o];n+=`${inspectKey(String(s))}: ${inspectValue(e[s],t,r)}`}return n+=" }",n}function inspectArray(e,t,r){if(t>DEPTH_LIMIT)return"[Array]";let n="[";for(let i=0;i<e.length;i++){if(n+=0===i?" ":", ",i>=LIST_LIMIT){n+=`... ${e.length-LIST_LIMIT} more items`;break}n+=insLine truncated
|
||||
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */if(!isLE$3)throw new Error("Non little-endian hardware is not supported");function utf8ToBytes$2(e){if("string"!=typeof e)throw new Error("utf8ToBytes expected string, got "+typeof e);return new Uint8Array((new TextEncoder).encode(e))}function toBytes$1(e){if("string"==typeof e&&(e=utf8ToBytes$2(e)),!u8a$2(e))throw new Error("expected Uint8Array, got "+typeof e);return e}function concatBytes$3(...e){const t=new Uint8Array(e.reduce((e,t)=>e+t.length,0));let r=0;return e.forEach(e=>{if(!u8a$2(e))throw new Error("Uint8Array expected");t.set(e,r),r+=e.length}),t}let Hash$1=class{clone(){return this._cloneInto()}};function wrapConstructor$1(e){const t=t=>e().update(toBytes$1(t)).digest(),r=e();return t.outputLen=r.outputLen,t.blockLen=r.blockLen,t.create=()=>e(),t}function randomBytes$2(e=32){if(crypto$2&&"function"==typeof crypto$2.getRandomValues)return crypto$2.getRandomValues(new Uint8Array(e));throw new Error("crypto.getRandomValues must be defined")}function setBigUint64$1(e,t,r,n){if("function"==typeof e.setBigUint64)return e.setBigUint64(t,r,n);const i=BigInt(32),o=BigInt(4294967295),s=Number(r>>i&o),a=Number(r&o),l=n?4:0,c=n?0:4;e.setUint32(t+l,s,n),e.setUint32(t+c,a,n)}let SHA2$1=class extends Hash$1{constructor(e,t,r,n){super(),this.blockLen=e,this.outputLen=t,this.padOffset=r,this.isLE=n,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(e),this.view=createView$3(this.buffer)}update(e){exists$1(this);const{view:t,buffer:r,blockLen:n}=this,i=(e=toBytes$1(e)).length;for(let o=0;o<i;){const s=Math.min(n-this.pos,i-o);if(s===n){const t=createView$3(e);for(;n<=i-o;o+=n)this.process(t,o);continue}r.set(e.subarray(o,o+s),this.pos),this.pos+=s,o+=s,this.pos===n&&(this.process(t,0),this.pos=0)}return this.length+=e.length,this.roundClean(),this}digestInto(e){exists$1(this),output$1(e,this),this.finished=!0;const{buffer:t,view:r,blockLen:n,isLE:i}=this;let{pos:o}=this;t[o++]=128,this.buffer.subarray(o).fill(0),this.padOffset>n-o&&(this.process(r,0),o=0);for(let u=o;u<n;u++)t[u]=0;setBigUint64$1(r,n-8,BigInt(8*this.length),i),this.process(r,0);const s=createView$3(e),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const l=a/4,c=this.get();if(l>c.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u<l;u++)s.setUint32(4*u,c[u],i)}digest(){const{buffer:e,outputLen:t}=this;this.digestInto(e);const r=e.slice(0,t);return this.destroy(),r}_cloneInto(e){e||(e=new this.constructor),e.set(...this.get());const{blockLen:t,buffer:r,length:n,finished:i,destroyed:o,pos:s}=this;return e.length=n,e.pos=s,e.finished=i,e.destroyed=o,n%t&&e.buffer.set(r),e}};const Chi$3=(e,t,r)=>e&t^~e&r,Maj$3=(e,t,r)=>e&t^e&r^t&r,SHA256_K$3=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),IV$1=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),SHA256_W$3=new Uint32Array(64);let SHA256$1=class extends SHA2$1{constructor(){super(64,32,8,!1),this.A=0|IV$1[0],this.B=0|IV$1[1],this.C=0|IV$1[2],this.D=0|IV$1[3],this.E=0|IV$1[4],this.F=0|IV$1[5],this.G=0|IV$1[6],this.H=0|IV$1[7]}get(){const{A:e,B:t,C:r,D:n,E:i,F:o,G:s,H:a}=this;return[e,t,r,n,i,o,s,a]}set(e,t,r,n,i,o,s,a){this.A=0|e,this.B=0|t,this.C=0|r,this.D=0|n,this.E=0|i,this.F=0|o,this.G=0|s,this.H=0|a}process(e,t){for(let u=0;u<16;u++,t+=4)SHA256_W$3[u]=e.getUint32(t,!1);for(let u=16;u<64;u++){const e=SHA256_W$3[u-15],t=SHA256_W$3[u-2],r=rotr$3(e,7)^rotr$3(e,18)^e>>>3,n=rotr$3(t,17)^rotr$3(t,19)^t>>>10;SHA256_W$3[u]=n+SHA256_W$3[u-7]+r+SHA256_W$3[u-16]|0}let{A:r,B:n,C:i,D:o,E:s,F:a,G:l,H:c}=this;for(let u=0;u<64;u++){const e=c+(rotr$3(s,6)^rotr$3(s,11)^rotr$3(s,25))+Chi$3(s,a,l)+SHA256_K$3[u]+SHA256_W$3[u]|0,t=(rotr$3(r,2)^rotr$3(r,13)^rotr$3(r,22))+Maj$3(r,n,i)|0;c=l,l=a,a=s,s=o+e|0,o=i,i=n,n=r,r=e+t|0}r=r+this.A|0,n=n+this.B|0,i=i+this.C|0,o=o+this.D|0,s=s+this.E|0,a=a+this.F|0,l=l+this.G|0,c=c+this.H|0,this.set(r,n,i,o,s,a,l,c)}roundClean(){SHA256_W$3.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}};const sha256$3=wrapConstructor$1(()=>new SHA256$1),_0n$a=BigInt(0),_1n$9=BigInt(1),_2n$6=BigInt(2),u8a$1=e=>e instanceof Uint8Array,hexes$4=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function bytesToHex$4(e){if(!u8a$1(e))throw new Error("Uint8Array expected");let t="";for(let r=0;r<e.length;r++)t+=hexes$4[e[r]];return t}function numberToHexUnpadded$2(e){const t=e.toString(16);return 1&t.length?`0${t}`:t}function hexToNumber$2(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);return BigInt(""===e?"0":`0x${e}`)}function hexToBytes$4(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const r=new Uint8Array(t/2);for(let n=0;n<r.length;n++){const t=2*n,i=e.slice(t,t+2),o=Number.parseInt(i,16);if(Number.isNaN(o)||o<0)throw new Error("Invalid byte sequence");r[n]=o}return r}function bytesToNumberBE$1(e){return hexToNumber$2(bytesToHex$4(e))}function bytesToNumberLE$1(e){if(!u8a$1(e))throw new Error("Uint8Array expected");return hexToNumber$2(bytesToHex$4(Uint8Array.from(e).reverse()))}function numberToBytesBE$1(e,t){return hexToBytes$4(e.toString(16).padStart(2*t,"0"))}function numberToBytesLE$1(e,t){return numberToBytesBE$1(e,t).reverse()}function numberToVarBytesBE(e){return hexToBytes$4(numberToHexUnpadded$2(e))}function ensureBytes$1(e,t,r){let n;if("string"==typeof t)try{n=hexToBytes$4(t)}catch(o){throw new Error(`${e} must be valid hex string, got "${t}". Cause: ${o}`)}else{if(!u8a$1(t))throw new Error(`${e} must be hex string or Uint8Array`);n=Uint8Array.from(t)}const i=n.length;if("number"==typeof r&&i!==r)throw new Error(`${e} expected ${r} bytes, got ${i}`);return n}function concatBytes$2(...e){const t=new Uint8Array(e.reduce((e,t)=>e+t.length,0));let r=0;return e.forEach(e=>{if(!u8a$1(e))throw new Error("Uint8Array expected");t.set(e,r),r+=e.length}),t}function equalBytes$2(e,t){if(e.length!==t.length)return!1;for(let r=0;r<e.length;r++)if(e[r]!==t[r])return!1;return!0}function utf8ToBytes$1(e){if("string"!=typeof e)throw new Error("utf8ToBytes expected string, got "+typeof e);return new Uint8Array((new TextEncoder).encode(e))}function bitLen$1(e){let t;for(t=0;e>_0n$a;e>>=_1n$9,t+=1);return t}function bitGet(e,t){return e>>BigInt(t)&_1n$9}const bitSet=(e,t,r)=>e|(r?_1n$9:_0n$a)<<BigInt(t),bitMask$1=e=>(_2n$6<<BigInt(e-1))-_1n$9,u8n=e=>new Uint8Array(e),u8fr=e=>Uint8Array.from(e);function createHmacDrbg$1(e,t,r){if("number"!=typeof e||e<2)throw new Error("hashLen must be a number");if("number"!=typeof t||t<2)throw new Error("qByteLen must be a number");if("function"!=typeof r)throw new Error("hmacFn must be a function");let n=u8n(e),i=u8n(e),o=0;const s=()=>{n.fill(1),i.fill(0),o=0},a=(...e)=>r(i,n,...e),l=(e=u8n())=>{i=a(u8fr([0]),e),n=a(),0!==e.length&&(i=a(u8fr([1]),e),n=a())},c=()=>{if(o++>=1e3)throw new Error("drbg: tried 1000 values");let e=0;const r=[];for(;e<t;){n=a();const t=n.slice();r.push(t),e+=n.length}return concatBytes$2(...r)};return(e,t)=>{let r;for(s(),l(e);!(r=t(c()));)l();return s(),r}}const validatorFns={bigint:e=>"bigint"==typeof e,function:e=>"function"==typeof e,boolean:e=>"boolean"==typeof e,string:e=>"string"==typeof e,stringOrUint8Array:e=>"string"==typeof e||e instanceof Uint8Array,isSafeInteger:e=>Number.isSafeInteger(e),array:e=>Array.isArray(e),field:(e,t)=>t.Fp.isValid(e),hash:e=>"function"==typeof e&&Number.isSafeInteger(e.outputLen)};function validateObject$1(e,t,r={}){const n=(t,r,n)=>{const i=validatorFns[r];if("function"!=typeof i)throw new Error(`Invalid validator "${r}", expected function`);const o=e[t];if(!(n&&void 0===o||i(o,e)))throw new Error(`Invalid param ${String(t)}=${o} (${typeof o}), expected ${r}`)};for(const[i,o]of Object.entries(t))n(i,o,!1);for(const[i,o]of Object.entries(r))n(i,o,!0);return e}const ut=Object.freeze(Object.defineProperty({__proto__:null,bitGet:bitGet,bitLen:bitLen$1,bitMask:bitMask$1,bitSet:bitSet,bytesToHex:bytesToHex$4,bytesToNumberBE:bytesToNumberBE$1,bytesToNumberLE:bytesToNumberLE$1,concatBytes:concatBytes$2,createHmacDrbg:createHmacDrbg$1,ensureBytes:ensureBytes$1,equalBytes:equalBytes$2,hexToBytes:hexToBytes$4,hexToNumber:hexToNumber$2,numberToBytesBE:numberToBytesBE$1,numberToBytesLE:numberToBytesLE$1,numberToHexUnpadded:numberToHexUnpadded$2,numberToVarBytesBE:numberToVarBytesBE,utf8ToBytes:utf8ToBytes$1,validateObject:validateObject$1},Symbol.toStringTag,{value:"Module"})),_0n$9=BigInt(0),_1n$8=BigInt(1),_2n$5=BigInt(2),_3n$4=BigInt(3),_4n$2=BigInt(4),_5n$1=BigInt(5),_8n$2=BigInt(8);
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function mod$2(e,t){const r=e%t;return r>=_0n$9?r:t+r}function pow(e,t,r){if(r<=_0n$9||t<_0n$9)throw new Error("Expected power/modulo > 0");if(r===_1n$8)return _0n$9;let n=_1n$8;for(;t>_0n$9;)t&_1n$8&&(n=n*e%r),e=e*e%r,t>>=_1n$8;return n}function pow2$2(e,t,r){let n=e;for(;t-- >_0n$9;)n*=n,n%=r;return n}function invert$2(e,t){if(e===_0n$9||t<=_0n$9)throw new Error(`invert: expected positive integers, got n=${e} mod=${t}`);let r=mod$2(e,t),n=t,i=_0n$9,o=_1n$8;for(;r!==_0n$9;){const e=n%r,t=i-o*(n/r);n=r,r=e,i=o,o=t}if(n!==_1n$8)throw new Error("invert: does not exist");return mod$2(i,t)}function tonelliShanks$1(e){const t=(e-_1n$8)/_2n$5;let r,n,i;for(r=e-_1n$8,n=0;r%_2n$5===_0n$9;r/=_2n$5,n++);for(i=_2n$5;i<e&&pow(i,t,e)!==e-_1n$8;i++);if(1===n){const t=(e+_1n$8)/_4n$2;return function(e,r){const n=e.pow(r,t);if(!e.eql(e.sqr(n),r))throw new Error("Cannot find square root");return n}}const o=(r+_1n$8)/_2n$5;return function(e,s){if(e.pow(s,t)===e.neg(e.ONE))throw new Error("Cannot find square root");let a=n,l=e.pow(e.mul(e.ONE,i),r),c=e.pow(s,o),u=e.pow(s,r);for(;!e.eql(u,e.ONE);){if(e.eql(u,e.ZERO))return e.ZERO;let t=1;for(let n=e.sqr(u);t<a&&!e.eql(n,e.ONE);t++)n=e.sqr(n);const r=e.pow(l,_1n$8<<BigInt(a-t-1));l=e.sqr(r),c=e.mul(c,r),u=e.mul(u,l),a=t}return c}}function FpSqrt$1(e){if(e%_4n$2===_3n$4){const t=(e+_1n$8)/_4n$2;return function(e,r){const n=e.pow(r,t);if(!e.eql(e.sqr(n),r))throw new Error("Cannot find square root");return n}}if(e%_8n$2===_5n$1){const t=(e-_5n$1)/_8n$2;return function(e,r){const n=e.mul(r,_2n$5),i=e.pow(n,t),o=e.mul(r,i),s=e.mul(e.mul(o,_2n$5),i),a=e.mul(o,e.sub(s,e.ONE));if(!e.eql(e.sqr(a),r))throw new Error("Cannot find square root");return a}}return tonelliShanks$1(e)}BigInt(9),BigInt(16);const FIELD_FIELDS$1=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function validateField$1(e){const t=FIELD_FIELDS$1.reduce((e,t)=>(e[t]="function",e),{ORDER:"bigint",MASK:"bigint",BYTES:"isSafeInteger",BITS:"isSafeInteger"});return validateObject$1(e,t)}function FpPow$1(e,t,r){if(r<_0n$9)throw new Error("Expected power > 0");if(r===_0n$9)return e.ONE;if(r===_1n$8)return t;let n=e.ONE,i=t;for(;r>_0n$9;)r&_1n$8&&(n=e.mul(n,i)),i=e.sqr(i),r>>=_1n$8;return n}function FpInvertBatch$1(e,t){const r=new Array(t.length),n=t.reduce((t,n,i)=>e.is0(n)?t:(r[i]=t,e.mul(t,n)),e.ONE),i=e.inv(n);return t.reduceRight((t,n,i)=>e.is0(n)?t:(r[i]=e.mul(t,r[i]),e.mul(t,n)),i),r}function nLength$1(e,t){const r=void 0!==t?t:e.toString(2).length;return{nBitLength:r,nByteLength:Math.ceil(r/8)}}function Field$1(e,t,r=!1,n={}){if(e<=_0n$9)throw new Error(`Expected Field ORDER > 0, got ${e}`);const{nBitLength:i,nByteLength:o}=nLength$1(e,t);if(o>2048)throw new Error("Field lengths over 2048 bytes are not supported");const s=FpSqrt$1(e),a=Object.freeze({ORDER:e,BITS:i,BYTES:o,MASK:bitMask$1(i),ZERO:_0n$9,ONE:_1n$8,create:t=>mod$2(t,e),isValid:t=>{if("bigint"!=typeof t)throw new Error("Invalid field element: expected bigint, got "+typeof t);return _0n$9<=t&&t<e},is0:e=>e===_0n$9,isOdd:e=>(e&_1n$8)===_1n$8,neg:t=>mod$2(-t,e),eql:(e,t)=>e===t,sqr:t=>mod$2(t*t,e),add:(t,r)=>mod$2(t+r,e),sub:(t,r)=>mod$2(t-r,e),mul:(t,r)=>mod$2(t*r,e),pow:(e,t)=>FpPow$1(a,e,t),div:(t,r)=>mod$2(t*invert$2(r,e),e),sqrN:e=>e*e,addN:(e,t)=>e+t,subN:(e,t)=>e-t,mulN:(e,t)=>e*t,inv:t=>invert$2(t,e),sqrt:n.sqrt||(e=>s(a,e)),invertBatch:e=>FpInvertBatch$1(a,e),cmov:(e,t,r)=>r?t:e,toBytes:e=>r?numberToBytesLE$1(e,o):numberToBytesBE$1(e,o),fromBytes:e=>{if(e.length!==o)throw new Error(`Fp.fromBytes: expected ${o}, got ${e.length}`);return r?bytesToNumberLE$1(e):bytesToNumberBE$1(e)}});return Object.freeze(a)}function getFieldBytesLength$1(e){if("bigint"!=typeof e)throw new Error("field order must be bigint");const t=e.toString(2).length;return Math.ceil(t/8)}function getMinHashLength$1(e){const t=getFieldBytesLength$1(e);return t+Math.ceil(t/2)}function mapHashToField$1(e,t,r=!1){const n=e.length,i=getFieldBytesLength$1(t),o=getMinHashLength$1(t);if(n<16||n<o||n>1024)throw new Error(`expected ${o}-1024 bytes of input, got ${n}`);const s=mod$2(r?bytesToNumberBE$1(e):bytesToNumberLE$1(e),t-_1n$8)+_1n$8;return r?numberToBytesLE$1(s,i):numberToBytesBE$1(s,i)}
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$8=BigInt(0),_1n$7=BigInt(1);function wNAF$1(e,t){const r=(e,t)=>{const r=t.negate();return e?r:t},n=e=>({windows:Math.ceil(t/e)+1,windowSize:2**(e-1)});return{constTimeNegate:r,unsafeLadder(t,r){let n=e.ZERO,i=t;for(;r>_0n$8;)r&_1n$7&&(n=n.add(i)),i=i.double(),r>>=_1n$7;return n},precomputeWindow(e,t){const{windows:r,windowSize:i}=n(t),o=[];let s=e,a=s;for(let n=0;n<r;n++){a=s,o.push(a);for(let e=1;e<i;e++)a=a.add(s),o.push(a);s=a.double()}return o},wNAF(t,i,o){const{windows:s,windowSize:a}=n(t);let l=e.ZERO,c=e.BASE;const u=BigInt(2**t-1),h=2**t,d=BigInt(t);for(let e=0;e<s;e++){const t=e*a;let n=Number(o&u);o>>=d,n>a&&(n-=h,o+=_1n$7);const s=t,p=t+Math.abs(n)-1,f=e%2!=0,g=n<0;0===n?c=c.add(r(f,i[s])):l=l.add(r(g,i[p]))}return{p:l,f:c}},wNAFCached(e,t,r,n){const i=e._WINDOW_SIZE||1;let o=t.get(e);return o||(o=this.precomputeWindow(e,i),1!==i&&t.set(e,n(o))),this.wNAF(i,o,r)}}}function validateBasic(e){return validateField$1(e.Fp),validateObject$1(e,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...nLength$1(e.n,e.nBitLength),...e,p:e.Fp.ORDER})}
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function validatePointOpts(e){const t=validateBasic(e);validateObject$1(t,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});const{endo:r,Fp:n,a:i}=t;if(r){if(!n.eql(i,n.ZERO))throw new Error("Endomorphism can only be defined for Koblitz curves that have a=0");if("object"!=typeof r||"bigint"!=typeof r.beta||"function"!=typeof r.splitScalar)throw new Error("Expected endomorphism with beta: bigint and splitScalar: function")}return Object.freeze({...t})}const{bytesToNumberBE:b2n,hexToBytes:h2b}=ut,DER$1={Err:class extends Error{constructor(e=""){super(e)}},_parseInt(e){const{Err:t}=DER$1;if(e.length<2||2!==e[0])throw new t("Invalid signature integer tag");const r=e[1],n=e.subarray(2,r+2);if(!r||n.length!==r)throw new t("Invalid signature integer: wrong length");if(128&n[0])throw new t("Invalid signature integer: negative");if(0===n[0]&&!(128&n[1]))throw new t("Invalid signature integer: unnecessary leading zero");return{d:b2n(n),l:e.subarray(r+2)}},toSig(e){const{Err:t}=DER$1,r="string"==typeof e?h2b(e):e;if(!(r instanceof Uint8Array))throw new Error("ui8a expected");let n=r.length;if(n<2||48!=r[0])throw new t("Invalid signature tag");if(r[1]!==n-2)throw new t("Invalid signature: incorrect length");const{d:i,l:o}=DER$1._parseInt(r.subarray(2)),{d:s,l:a}=DER$1._parseInt(o);if(a.length)throw new t("Invalid signature: left bytes after parsing");return{r:i,s:s}},hexFromSig(e){const t=e=>8&Number.parseInt(e[0],16)?"00"+e:e,r=e=>{const t=e.toString(16);return 1&t.length?`0${t}`:t},n=t(r(e.s)),i=t(r(e.r)),o=n.length/2,s=i.length/2,a=r(o),l=r(s);return`30${r(s+o+4)}02${l}${i}02${a}${n}`}},_0n$7=BigInt(0),_1n$6=BigInt(1);BigInt(2);const _3n$3=BigInt(3);function weierstrassPoints(e){const t=validatePointOpts(e),{Fp:r}=t,n=t.toBytes||((e,t)=>{const n=t.toAffine();return concatBytes$2(Uint8Array.from([4]),r.toBytes(n.x),r.toBytes(n.y))}),i=t.fromBytes||(e=>{const t=e.subarray(1);return{x:r.fromBytes(t.subarray(0,r.BYTES)),y:r.fromBytes(t.subarray(r.BYTES,2*r.BYTES))}});function o(e){const{a:n,b:i}=t,o=r.sqr(e),s=r.mul(o,e);return r.add(r.add(s,r.mul(e,n)),i)}if(!r.eql(r.sqr(t.Gy),o(t.Gx)))throw new Error("bad generator point: equation left != right");function s(e){return"bigint"==typeof e&&_0n$7<e&&e<t.n}function a(e){if(!s(e))throw new Error("Expected valid bigint: 0 < bigint < curve.n")}function l(e){const{allowedPrivateKeyLengths:r,nByteLength:n,wrapPrivateKey:i,n:o}=t;if(r&&"bigint"!=typeof e){if(e instanceof Uint8Array&&(e=bytesToHex$4(e)),"string"!=typeof e||!r.includes(e.length))throw new Error("Invalid key");e=e.padStart(2*n,"0")}let s;try{s="bigint"==typeof e?e:bytesToNumberBE$1(ensureBytes$1("private key",e,n))}catch(error){throw new Error(`private key must be ${n} bytes, hex or bigint, not ${typeof e}`)}return i&&(s=mod$2(s,o)),a(s),s}const c=new Map;function u(e){if(!(e instanceof h))throw new Error("ProjectivePoint expected")}class h{constructor(e,t,n){if(this.px=e,this.py=t,this.pz=n,null==e||!r.isValid(e))throw new Error("x required");if(null==t||!r.isValid(t))throw new Error("y required");if(null==n||!r.isValid(n))throw new Error("z required")}static fromAffine(e){const{x:t,y:n}=e||{};if(!e||!r.isValid(t)||!r.isValid(n))throw new Error("invalid affine point");if(e instanceof h)throw new Error("projective point not allowed");const i=e=>r.eql(e,r.ZERO);return i(t)&&i(n)?h.ZERO:new h(t,n,r.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(e){const t=r.invertBatch(e.map(e=>e.pz));return e.map((e,r)=>e.toAffine(t[r])).map(h.fromAffine)}static fromHex(e){const t=h.fromAffine(i(ensureBytes$1("pointHex",e)));return t.assertValidity(),t}static fromPrivateKey(e){return h.BASE.multiply(l(e))}_setWindowSize(e){this._WINDOW_SIZE=e,c.delete(this)}assertValidity(){if(this.is0()){if(t.allowInfinityPoint&&!r.is0(this.py))return;throw new Error("bad point: ZERO")}const{x:e,y:n}=this.toAffine();if(!r.isValid(e)||!r.isValid(n))throw new Error("bad point: x or y not FE");const i=r.sqr(n),s=o(e);if(!r.eql(i,s))throw new Error("bad point: equation left != right");if(!this.isTorsionFree())throw new Error("bad point: not in prime-order subgroup")}hasEvenY(){const{y:e}=this.toAffine();if(r.isOdd)return!r.isOdd(e);throw new Error("Field doesn't support isOdd")}equals(e){u(e);const{px:t,py:n,pz:i}=this,{px:o,py:s,pz:a}=e,l=r.eql(r.mul(t,a),r.mul(o,i)),c=r.eql(r.mul(n,a),r.mul(s,i));return l&&c}negate(){return new h(this.px,r.neg(this.py),this.pz)}double(){const{a:e,b:n}=t,i=r.mul(n,_3n$3),{px:o,py:s,pz:a}=this;let l=r.ZERO,c=r.ZERO,u=r.ZERO,d=r.mul(o,o),p=r.mul(s,s),f=r.mul(a,a),g=r.mul(o,s);return g=r.add(g,g),u=r.mul(o,a),u=r.add(u,u),l=r.mul(e,u),c=r.mul(i,f),c=r.add(l,c),l=r.sub(p,c),c=r.add(p,c),c=r.mul(l,c),l=r.mul(g,l),u=r.mul(i,u),f=r.mul(e,f),g=r.sub(d,f),g=r.mul(e,g)Line truncated
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||
function getHash(e){return{hash:e,hmac:(t,...r)=>hmac$2(e,t,concatBytes$3(...r)),randomBytes:randomBytes$2}}function createCurve(e,t){const r=t=>weierstrass$2({...e,...getHash(t)});return Object.freeze({...r(t),create:r})}
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */hmac$2.create=(e,t)=>new HMAC$1(e,t);const secp256k1P=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),secp256k1N=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),_1n$5=BigInt(1),_2n$4=BigInt(2),divNearest$2=(e,t)=>(e+t/_2n$4)/t;function sqrtMod$2(e){const t=secp256k1P,r=BigInt(3),n=BigInt(6),i=BigInt(11),o=BigInt(22),s=BigInt(23),a=BigInt(44),l=BigInt(88),c=e*e*e%t,u=c*c*e%t,h=pow2$2(u,r,t)*u%t,d=pow2$2(h,r,t)*u%t,p=pow2$2(d,_2n$4,t)*c%t,f=pow2$2(p,i,t)*p%t,g=pow2$2(f,o,t)*f%t,m=pow2$2(g,a,t)*g%t,y=pow2$2(m,l,t)*m%t,b=pow2$2(y,a,t)*g%t,v=pow2$2(b,r,t)*u%t,_=pow2$2(v,s,t)*f%t,E=pow2$2(_,n,t)*c%t,w=pow2$2(E,_2n$4,t);if(!Fp.eql(Fp.sqr(w),e))throw new Error("Cannot find square root");return w}const Fp=Field$1(secp256k1P,void 0,void 0,{sqrt:sqrtMod$2}),secp256k1$1=createCurve({a:BigInt(0),b:BigInt(7),Fp:Fp,n:secp256k1N,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:!0,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:e=>{const t=secp256k1N,r=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),n=-_1n$5*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),i=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),o=r,s=BigInt("0x100000000000000000000000000000000"),a=divNearest$2(o*e,t),l=divNearest$2(-n*e,t);let c=mod$2(e-a*r-l*i,t),u=mod$2(-a*n-l*o,t);const h=c>s,d=u>s;if(h&&(c=t-c),d&&(u=t-u),c>s||u>s)throw new Error("splitScalar: Endomorphism failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}}},sha256$3),_0n$6=BigInt(0),fe=e=>"bigint"==typeof e&&_0n$6<e&&e<secp256k1P,ge=e=>"bigint"==typeof e&&_0n$6<e&&e<secp256k1N,TAGGED_HASH_PREFIXES$1={};function taggedHash$1(e,...t){let r=TAGGED_HASH_PREFIXES$1[e];if(void 0===r){const t=sha256$3(Uint8Array.from(e,e=>e.charCodeAt(0)));r=concatBytes$2(t,t),TAGGED_HASH_PREFIXES$1[e]=r}return sha256$3(concatBytes$2(r,...t))}const pointToBytes$1=e=>e.toRawBytes(!0).slice(1),numTo32b=e=>numberToBytesBE$1(e,32),modP=e=>mod$2(e,secp256k1P),modN=e=>mod$2(e,secp256k1N),Point$2=secp256k1$1.ProjectivePoint,GmulAdd=(e,t,r)=>Point$2.BASE.multiplyAndAddUnsafe(e,t,r);function schnorrGetExtPubKey$1(e){let t=secp256k1$1.utils.normPrivateKeyToScalar(e),r=Point$2.fromPrivateKey(t);return{scalar:r.hasEvenY()?t:modN(-t),bytes:pointToBytes$1(r)}}function lift_x$1(e){if(!fe(e))throw new Error("bad x: need 0 < x < p");const t=modP(e*e);let r=sqrtMod$2(modP(t*e+BigInt(7)));r%_2n$4!==_0n$6&&(r=modP(-r));const n=new Point$2(e,r,_1n$5);return n.assertValidity(),n}function challenge$1(...e){return modN(bytesToNumberBE$1(taggedHash$1("BIP0340/challenge",...e)))}function schnorrGetPublicKey$1(e){return schnorrGetExtPubKey$1(e).bytes}function schnorrSign$1(e,t,r=randomBytes$2(32)){const n=ensureBytes$1("message",e),{bytes:i,scalar:o}=schnorrGetExtPubKey$1(t),s=ensureBytes$1("auxRand",r,32),a=numTo32b(o^bytesToNumberBE$1(taggedHash$1("BIP0340/aux",s))),l=taggedHash$1("BIP0340/nonce",a,i,n),c=modN(bytesToNumberBE$1(l));if(c===_0n$6)throw new Error("sign failed: k is zero");const{bytes:u,scalar:h}=schnorrGetExtPubKey$1(c),d=challenge$1(u,i,n),p=new Uint8Array(64);if(p.set(u,0),p.set(numTo32b(modN(h+d*o)),32),!schnorrVerify$1(p,n,i))throw new Error("sign: Invalid signature produced");return p}function schnorrVerify$1(e,t,r){const n=ensureBytes$1("signature",e,64),i=ensureBytes$1("message",t),o=ensureBytes$1("publicKey",r,32);try{const e=lift_x$1(bytesToNumberBE$1(o)),t=bytesToNumberBE$1(n.subarray(0,32));if(!fe(t))return!1;const r=bytesToNumberBE$1(n.subarray(32,64));if(!ge(r))return!1;const s=challenge$1(numTo32b(t),pointToBytes$1(e),i),a=GmulAdd(e,r,modN(-s));return!(!a||!a.hasEvenY()||a.toAffine().x!==t)}catch(error){return!1}}const schnorr$1=(()=>({getPublicKey:schnorrGetPublicKey$1,sign:schnorrSign$1,verify:schnorrVerify$1,utils:{randomPrivateKey:secp256k1$1.utils.randomPrivateKey,lift_x:lift_x$1,pointToBytes:pointToBytes$1,numberToBytesBE:numberToBytesBE$1,bytesToNumberBE:bytesToNumberBE$1,taggedHash:taggedHash$1,mod:mod$2}}))(),crypto$1="object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0,u8a=e=>e instanceof Uint8Array,createView$2=e=>new DataView(e.buffer,e.byteOffset,e.byteLength),rotr$2=(e,t)=>e<<32-t|e>>>t,isLE$2=68===new Uint8Array(new Uint32Array([287454020]).buffer)[0];if(!isLE$2)throw new Error("Non little-endian hardware is not supported");const hexes$3=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex$3(e){if(!u8a(e))throw new Error("Uint8Array expected");let t="";for(let r=0;r<e.length;r++)t+=hexes$3[e[r]];return t}function hexToBytes$3(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const r=new Uint8Array(t/2);Line truncated
|
||||
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||
function assertNumber(e){if(!Number.isSafeInteger(e))throw new Error(`Wrong integer: ${e}`)}function chain$1(...e){const t=(e,t)=>r=>e(t(r)),r=Array.from(e).reverse().reduce((e,r)=>e?t(e,r.encode):r.encode,void 0),n=e.reduce((e,r)=>e?t(e,r.decode):r.decode,void 0);return{encode:r,decode:n}}function alphabet$1(e){return{encode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("alphabet.encode input should be an array of numbers");return t.map(t=>{if(assertNumber(t),t<0||t>=e.length)throw new Error(`Digit index outside alphabet: ${t} (alphabet: ${e.length})`);return e[t]})},decode:t=>{if(!Array.isArray(t)||t.length&&"string"!=typeof t[0])throw new Error("alphabet.decode input should be array of strings");return t.map(t=>{if("string"!=typeof t)throw new Error(`alphabet.decode: not string element=${t}`);const r=e.indexOf(t);if(-1===r)throw new Error(`Unknown letter: "${t}". Allowed: ${e}`);return r})}}}function join$1(e=""){if("string"!=typeof e)throw new Error("join separator should be string");return{encode:t=>{if(!Array.isArray(t)||t.length&&"string"!=typeof t[0])throw new Error("join.encode input should be array of strings");for(let e of t)if("string"!=typeof e)throw new Error(`join.encode: non-string input=${e}`);return t.join(e)},decode:t=>{if("string"!=typeof t)throw new Error("join.decode input should be string");return t.split(e)}}}function padding$1(e,t="="){if(assertNumber(e),"string"!=typeof t)throw new Error("padding chr should be string");return{encode(r){if(!Array.isArray(r)||r.length&&"string"!=typeof r[0])throw new Error("padding.encode input should be array of strings");for(let e of r)if("string"!=typeof e)throw new Error(`padding.encode: non-string input=${e}`);for(;r.length*e%8;)r.push(t);return r},decode(r){if(!Array.isArray(r)||r.length&&"string"!=typeof r[0])throw new Error("padding.encode input should be array of strings");for(let e of r)if("string"!=typeof e)throw new Error(`padding.decode: non-string input=${e}`);let n=r.length;if(n*e%8)throw new Error("Invalid padding: string should have whole number of bytes");for(;n>0&&r[n-1]===t;n--)if(!((n-1)*e%8))throw new Error("Invalid padding: string has too much padding");return r.slice(0,n)}}}function normalize(e){if("function"!=typeof e)throw new Error("normalize fn should be function");return{encode:e=>e,decode:t=>e(t)}}function convertRadix(e,t,r){if(t<2)throw new Error(`convertRadix: wrong from=${t}, base cannot be less than 2`);if(r<2)throw new Error(`convertRadix: wrong to=${r}, base cannot be less than 2`);if(!Array.isArray(e))throw new Error("convertRadix: data should be array");if(!e.length)return[];let n=0;const i=[],o=Array.from(e);for(o.forEach(e=>{if(assertNumber(e),e<0||e>=t)throw new Error(`Wrong integer: ${e}`)});;){let e=0,s=!0;for(let i=n;i<o.length;i++){const a=o[i],l=t*e+a;if(!Number.isSafeInteger(l)||t*e/t!==e||l-a!==t*e)throw new Error("convertRadix: carry overflow");if(e=l%r,o[i]=Math.floor(l/r),!Number.isSafeInteger(o[i])||o[i]*r+e!==l)throw new Error("convertRadix: carry overflow");s&&(o[i]?s=!1:n=i)}if(i.push(e),s)break}for(let s=0;s<e.length-1&&0===e[s];s++)i.push(0);return i.reverse()}wrapConstructor(()=>new SHA224);const gcd$1=(e,t)=>t?gcd$1(t,e%t):e,radix2carry$1=(e,t)=>e+(t-gcd$1(e,t));function convertRadix2$1(e,t,r,n){if(!Array.isArray(e))throw new Error("convertRadix2: data should be array");if(t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(r<=0||r>32)throw new Error(`convertRadix2: wrong to=${r}`);if(radix2carry$1(t,r)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${r} carryBits=${radix2carry$1(t,r)}`);let i=0,o=0;const s=2**r-1,a=[];for(const l of e){if(assertNumber(l),l>=2**t)throw new Error(`convertRadix2: invalid data word=${l} from=${t}`);if(i=i<<t|l,o+t>32)throw new Error(`convertRadix2: carry overflow pos=${o} from=${t}`);for(o+=t;o>=r;o-=r)a.push((i>>o-r&s)>>>0);i&=2**o-1}if(i=i<<r-o&s,!n&&o>=t)throw new Error("Excess padding");if(!n&&i)throw new Error(`Non-zero padding: ${i}`);return n&&o>0&&a.push(i>>>0),a}function radix(e){return assertNumber(e),{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix.encode input should be Uint8Array");return convertRadix(Array.from(t),256,e)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix.decode input should be array of strings");return Uint8Array.from(convertRadix(t,e,256))}}}function radix2$1(e,t=!1){if(assertNumber(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(radix2carry$1(8,e)>32||radix2carry$1(e,8)>32)throw new Error("radix2: carry overflow");return{encode:r=>{if(!(r instanceof Uint8Array))throw new Error("radix2.encode input should be Uint8Array");return convertRadix2$1(Array.from(r),8,e,!t)},decode:r=>{if(!Array.isArray(r)||r.length&&"number"!=typeof r[0])throw new Error("radix2.decode input should be array of strings");return Uint8Array.from(convertRadix2$1(r,e,8,t))}}}function unsafeWrapper$1(e){if("function"!=typeof e)thrLine truncated
|
||||
/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */Object.keys(CODERS).join(", ");const u32$1=e=>new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4)),isLE$1=68===new Uint8Array(new Uint32Array([287454020]).buffer)[0];if(!isLE$1)throw new Error("Non little-endian hardware is not supported");const hexes$2=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex$2(e){bytes(e);let t="";for(let r=0;r<e.length;r++)t+=hexes$2[e[r]];return t}const asciis$2={_0:48,_9:57,_A:65,_F:70,_a:97,_f:102};function asciiToBase16$2(e){return e>=asciis$2._0&&e<=asciis$2._9?e-asciis$2._0:e>=asciis$2._A&&e<=asciis$2._F?e-(asciis$2._A-10):e>=asciis$2._a&&e<=asciis$2._f?e-(asciis$2._a-10):void 0}function hexToBytes$2(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length,r=t/2;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const n=new Uint8Array(r);for(let i=0,o=0;i<r;i++,o+=2){const t=asciiToBase16$2(e.charCodeAt(o)),r=asciiToBase16$2(e.charCodeAt(o+1));if(void 0===t||void 0===r){const t=e[o]+e[o+1];throw new Error('hex string expected, got non-hex character "'+t+'" at index '+o)}n[i]=16*t+r}return n}function checkOpts$1(e,t){if(null==t||"object"!=typeof t)throw new Error("options must be defined");return Object.assign(e,t)}function equalBytes$1(e,t){if(e.length!==t.length)return!1;let r=0;for(let n=0;n<e.length;n++)r|=e[n]^t[n];return 0===r}const wrapCipher$1=(e,t)=>(Object.assign(t,e),t),BLOCK_SIZE$1=16,POLY$1=283;function mul2$1(e){return e<<1^POLY$1&-(e>>7)}function mul$1(e,t){let r=0;for(;t>0;t>>=1)r^=e&-(1&t),e=mul2$1(e);return r}const sbox$1=(()=>{let e=new Uint8Array(256);for(let r=0,n=1;r<256;r++,n^=mul2$1(n))e[r]=n;const t=new Uint8Array(256);t[0]=99;for(let r=0;r<255;r++){let n=e[255-r];n|=n<<8,t[e[r]]=255&(n^n>>4^n>>5^n>>6^n>>7^99)}return t})(),invSbox$1=sbox$1.map((e,t)=>sbox$1.indexOf(t)),rotr32_8$1=e=>e<<24|e>>>8,rotl32_8$1=e=>e<<8|e>>>24;function genTtable$1(e,t){if(256!==e.length)throw new Error("Wrong sbox length");const r=new Uint32Array(256).map((r,n)=>t(e[n])),n=r.map(rotl32_8$1),i=n.map(rotl32_8$1),o=i.map(rotl32_8$1),s=new Uint32Array(65536),a=new Uint32Array(65536),l=new Uint16Array(65536);for(let c=0;c<256;c++)for(let t=0;t<256;t++){const u=256*c+t;s[u]=r[c]^n[t],a[u]=i[c]^o[t],l[u]=e[c]<<8|e[t]}return{sbox:e,sbox2:l,T0:r,T1:n,T2:i,T3:o,T01:s,T23:a}}const tableEncoding$1=genTtable$1(sbox$1,e=>mul$1(e,3)<<24|e<<16|e<<8|mul$1(e,2)),tableDecoding$1=genTtable$1(invSbox$1,e=>mul$1(e,11)<<24|mul$1(e,13)<<16|mul$1(e,9)<<8|mul$1(e,14)),xPowers$1=(()=>{const e=new Uint8Array(16);for(let t=0,r=1;t<16;t++,r=mul2$1(r))e[t]=r;return e})();function expandKeyLE$1(e){bytes(e);const t=e.length;if(![16,24,32].includes(t))throw new Error(`aes: wrong key size: should be 16, 24 or 32, got: ${t}`);const{sbox2:r}=tableEncoding$1,n=u32$1(e),i=n.length,o=e=>applySbox$1(r,e,e,e,e),s=new Uint32Array(t+28);s.set(n);for(let a=i;a<s.length;a++){let e=s[a-1];a%i===0?e=o(rotr32_8$1(e))^xPowers$1[a/i-1]:i>6&&a%i===4&&(e=o(e)),s[a]=s[a-i]^e}return s}function expandKeyDecLE$1(e){const t=expandKeyLE$1(e),r=t.slice(),n=t.length,{sbox2:i}=tableEncoding$1,{T0:o,T1:s,T2:a,T3:l}=tableDecoding$1;for(let c=0;c<n;c+=4)for(let e=0;e<4;e++)r[c+e]=t[n-c-4+e];t.fill(0);for(let c=4;c<n-4;c++){const e=r[c],t=applySbox$1(i,e,e,e,e);r[c]=o[255&t]^s[t>>>8&255]^a[t>>>16&255]^l[t>>>24]}return r}function apply0123$1(e,t,r,n,i,o){return e[r<<8&65280|n>>>8&255]^t[i>>>8&65280|o>>>24&255]}function applySbox$1(e,t,r,n,i){return e[255&t|65280&r]|e[n>>>16&255|i>>>16&65280]<<16}function encrypt$4(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableEncoding$1;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123$1(s,a,t,r,n,i),c=e[l++]^apply0123$1(s,a,r,n,i,t),u=e[l++]^apply0123$1(s,a,n,i,t,r),h=e[l++]^apply0123$1(s,a,i,t,r,n);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox$1(o,t,r,n,i),s1:e[l++]^applySbox$1(o,r,n,i,t),s2:e[l++]^applySbox$1(o,n,i,t,r),s3:e[l++]^applySbox$1(o,i,t,r,n)}}function decrypt$4(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableDecoding$1;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123$1(s,a,t,i,n,r),c=e[l++]^apply0123$1(s,a,r,t,i,n),u=e[l++]^apply0123$1(s,a,n,r,t,i),h=e[l++]^apply0123$1(s,a,i,n,r,t);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox$1(o,t,i,n,r),s1:e[l++]^applySbox$1(o,r,t,i,n),s2:e[l++]^applySbox$1(o,n,r,t,i),s3:e[l++]^applySbox$1(o,i,n,r,t)}}function getDst(e,t){if(!t)return new Uint8Array(e);if(bytes(t),t.length<e)throw new Error(`aes: wrong destination length, expected at least ${e}, got: ${t.length}`);return t}function validateBlockDecrypt$1(e){if(bytes(e),e.length%BLOCK_SIZE$1!==0)throw new Error(`aes/(cbc-ecb).decrypt ciphertext should consist of blocks with size ${BLOCK_SIZE$1}`)}function validateBlockEncrypt$1(e,t,r){let n=e.length;const i=n%BLOCK_SIZE$1;if(!t&&0!==i)throw new Error("aecLine truncated
|
||||
/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */const _0n$5=BigInt(0),_1n$4=BigInt(1),_2n$3=BigInt(2),_3n$2=BigInt(3),_8n$1=BigInt(8),CURVE=Object.freeze({a:_0n$5,b:BigInt(7),P:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:_1n$4,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee")}),divNearest$1=(e,t)=>(e+t/_2n$3)/t,endo={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar(e){const{n:t}=CURVE,r=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),n=-_1n$4*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),i=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),o=r,s=BigInt("0x100000000000000000000000000000000"),a=divNearest$1(o*e,t),l=divNearest$1(-n*e,t);let c=mod$1(e-a*r-l*i,t),u=mod$1(-a*n-l*o,t);const h=c>s,d=u>s;if(h&&(c=t-c),d&&(u=t-u),c>s||u>s)throw new Error("splitScalarEndo: Endomorphism failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}},fieldLen=32,groupLen=32,compressedLen=fieldLen+1,uncompressedLen=2*fieldLen+1;function weierstrass$1(e){const{a:t,b:r}=CURVE,n=mod$1(e*e),i=mod$1(n*e);return mod$1(i+t*e+r)}const USE_ENDOMORPHISM=CURVE.a===_0n$5;function assertJacPoint(e){if(!(e instanceof JacobianPoint))throw new TypeError("JacobianPoint expected")}class JacobianPoint{constructor(e,t,r){this.x=e,this.y=t,this.z=r}static fromAffine(e){if(!(e instanceof Point$1))throw new TypeError("JacobianPoint#fromAffine: expected Point");return e.equals(Point$1.ZERO)?JacobianPoint.ZERO:new JacobianPoint(e.x,e.y,_1n$4)}static toAffineBatch(e){const t=invertBatch(e.map(e=>e.z));return e.map((e,r)=>e.toAffine(t[r]))}static normalizeZ(e){return JacobianPoint.toAffineBatch(e).map(JacobianPoint.fromAffine)}equals(e){assertJacPoint(e);const{x:t,y:r,z:n}=this,{x:i,y:o,z:s}=e,a=mod$1(n*n),l=mod$1(s*s),c=mod$1(t*l),u=mod$1(i*a),h=mod$1(mod$1(r*s)*l),d=mod$1(mod$1(o*n)*a);return c===u&&h===d}negate(){return new JacobianPoint(this.x,mod$1(-this.y),this.z)}double(){const{x:e,y:t,z:r}=this,n=mod$1(e*e),i=mod$1(t*t),o=mod$1(i*i),s=e+i,a=mod$1(_2n$3*(mod$1(s*s)-n-o)),l=mod$1(_3n$2*n),c=mod$1(l*l),u=mod$1(c-_2n$3*a),h=mod$1(l*(a-u)-_8n$1*o),d=mod$1(_2n$3*t*r);return new JacobianPoint(u,h,d)}add(e){assertJacPoint(e);const{x:t,y:r,z:n}=this,{x:i,y:o,z:s}=e;if(i===_0n$5||o===_0n$5)return this;if(t===_0n$5||r===_0n$5)return e;const a=mod$1(n*n),l=mod$1(s*s),c=mod$1(t*l),u=mod$1(i*a),h=mod$1(mod$1(r*s)*l),d=mod$1(mod$1(o*n)*a),p=mod$1(u-c),f=mod$1(d-h);if(p===_0n$5)return f===_0n$5?this.double():JacobianPoint.ZERO;const g=mod$1(p*p),m=mod$1(p*g),y=mod$1(c*g),b=mod$1(f*f-m-_2n$3*y),v=mod$1(f*(y-b)-h*m),_=mod$1(n*s*p);return new JacobianPoint(b,v,_)}subtract(e){return this.add(e.negate())}multiplyUnsafe(e){const t=JacobianPoint.ZERO;if("bigint"==typeof e&&e===_0n$5)return t;let r=normalizeScalar(e);if(r===_1n$4)return this;if(!USE_ENDOMORPHISM){let e=t,n=this;for(;r>_0n$5;)r&_1n$4&&(e=e.add(n)),n=n.double(),r>>=_1n$4;return e}let{k1neg:n,k1:i,k2neg:o,k2:s}=endo.splitScalar(r),a=t,l=t,c=this;for(;i>_0n$5||s>_0n$5;)i&_1n$4&&(a=a.add(c)),s&_1n$4&&(l=l.add(c)),c=c.double(),i>>=_1n$4,s>>=_1n$4;return n&&(a=a.negate()),o&&(l=l.negate()),l=new JacobianPoint(mod$1(l.x*endo.beta),l.y,l.z),a.add(l)}precomputeWindow(e){const t=USE_ENDOMORPHISM?128/e+1:256/e+1,r=[];let n=this,i=n;for(let o=0;o<t;o++){i=n,r.push(i);for(let t=1;t<2**(e-1);t++)i=i.add(n),r.push(i);n=i.double()}return r}wNAF(e,t){!t&&this.equals(JacobianPoint.BASE)&&(t=Point$1.BASE);const r=t&&t._WINDOW_SIZE||1;if(256%r)throw new Error("Point#wNAF: Invalid precomputation window, must be power of 2");let n=t&&pointPrecomputes$1.get(t);n||(n=this.precomputeWindow(r),t&&1!==r&&(n=JacobianPoint.normalizeZ(n),pointPrecomputes$1.set(t,n)));let i=JacobianPoint.ZERO,o=JacobianPoint.BASE;const s=1+(USE_ENDOMORPHISM?128/r:256/r),a=2**(r-1),l=BigInt(2**r-1),c=2**r,u=BigInt(r);for(let h=0;h<s;h++){const t=h*a;let r=Number(e&l);e>>=u,r>a&&(r-=c,e+=_1n$4);const s=t,d=t+Math.abs(r)-1,p=h%2!=0,f=r<0;0===r?o=o.add(constTimeNegate(p,n[s])):i=i.add(constTimeNegate(f,n[d]))}return{p:i,f:o}}multiply(e,t){let r,n,i=normalizeScalar(e);if(USE_ENDOMORPHISM){const{k1neg:e,k1:o,k2neg:s,k2:a}=endo.splitScalar(i);let{p:l,f:c}=this.wNAF(o,t),{p:u,f:h}=this.wNAF(a,t);l=constTimeNegate(e,l),u=constTimeNegate(s,u),u=new JacobianPoint(mod$1(u.x*endo.beta),u.y,u.z),r=l.add(u),n=c.add(h)}else{const{p:e,f:o}=this.wNAF(i,t);r=e,n=o}return JacobianPoint.normalizeZ([r,n])[0]}toAffine(e){const{x:t,y:r,z:n}=this,i=this.equals(JacobianPoint.ZERO);null==e&&(e=i?_8n$1:invert$1(n));const o=e,s=mod$1(o*o),a=mod$1(s*o),l=mod$1(t*s),c=mod$1(r*a),u=mod$1(n*o);if(i)return Point$1.ZERO;if(u!==_1n$4)throw new Error("invZ was invalid");return new Point$1(l,c)}}function constTLine truncated
|
||||
/*! For license information please see remotestorage.js.LICENSE.txt */function requireRemotestorage(){return hasRequiredRemotestorage||(hasRequiredRemotestorage=1,function(module,exports$1){var t;t=function(){return function(){var __webpack_modules__={548:function(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Access=void 0;class r{constructor(e){this.rs=e,this.reset(!1)}get scopes(){return Object.keys(this.scopeModeMap).map(e=>({name:e,mode:this.scopeModeMap[e]}))}get scopeParameter(){return this.scopes.map(e=>`${this._scopeNameForParameter(e)}:${e.mode}`).join(" ")}claim(e,t){if("string"!=typeof e||-1!==e.indexOf("/")||0===e.length)throw new Error("Scope should be a non-empty string without forward slashes");if("string"!=typeof t||!t.match(/^rw?$/))throw new Error("Mode should be either 'r' or 'rw'");this._adjustRootPaths(e),this.scopeModeMap[e]=t,this._notifyChange()}get(e){return this.scopeModeMap[e]}remove(e){const t={};for(const r in this.scopeModeMap)t[r]=this.scopeModeMap[r];this.reset(!1),delete t[e];for(const r in t)this._adjustRootPaths(r),this.scopeModeMap[r]=t[r];this._notifyChange()}checkPermission(e,t){const r=this.get(e);return r&&("r"===t||"rw"===r)}checkPathPermission(e,t){if(this.checkPermission("*",t))return!0;const r=this._getModuleName(e);return!!this.checkPermission(r,t)}reset(e=!0){this.rootPaths=[],this.scopeModeMap={},e&&this._notifyChange()}_getModuleName(e){if("/"!==e[0])throw new Error("Path should start with a slash");const t=e.replace(/^\/public/,"").match(/^\/([^/]*)\//);return t?t[1]:"*"}_adjustRootPaths(e){"*"in this.scopeModeMap||"*"===e?this.rootPaths=["/"]:e in this.scopeModeMap||(this.rootPaths.push("/"+e+"/"),this.rootPaths.push("/public/"+e+"/"))}_scopeNameForParameter(e){if("*"===e.name&&this.storageType){if("2012.04"===this.storageType)return"";if(this.storageType.match(/remotestorage-0[01]/))return"root"}return e.name}setStorageType(e){this.storageType=e}_notifyChange(){this.rs&&"function"==typeof this.rs._checkScopeChange&&this.rs._checkScopeChange()}static _rs_init(){}}t.Access=r,t.default=r},275:function(e,t,r){var n=this&&this.__awaiter||function(e,t,r,n){return new(r||(r=Promise))(function(i,o){function s(e){try{l(n.next(e))}catch(e){o(e)}}function a(e){try{l(n.throw(e))}catch(e){o(e)}}function l(e){var t;e.done?i(e.value):(t=e.value,t instanceof r?t:new r(function(e){e(t)})).then(s,a)}l((n=n.apply(e,t||[])).next())})},i=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.Authorize=void 0;const o=i(r(28)),s=i(r(334)),a=r(746),l=i(r(503)),c=r(368);let u;function h(e){const t=e||d.getLocation().href,r={};for(const[o,s]of new URL(t).searchParams)r[o]=s;const n=t.indexOf("#");if(-1===n)return r;const i=t.substring(n+1);return i.includes("=")?i.split("&").reduce(function(e,t){const r=t.split("=");if("state"===r[0]&&r[1].match(/rsDiscovery/)){let t=decodeURIComponent(r[1]);const n=t.substr(t.indexOf("rsDiscovery=")).split("&")[0].split("=")[1];e.rsDiscovery=JSON.parse(atob(n)),t=t.replace(new RegExp("&?rsDiscovery="+n),""),t.length>0&&(e.state=t)}else e[decodeURIComponent(r[0])]=decodeURIComponent(r[1]);return e},r):r}class d{static authorize(e,t){if((0,s.default)("[Authorize] authURL = ",t.authURL,"scope = ",t.scope,"redirectUri = ",t.redirectUri,"clientId = ",t.clientId,"response_type =",t.response_type),!t.scope)throw new Error("Cannot authorize due to undefined or empty scope; did you forget to access.claim()?");if("function"==typeof e._rememberPendingScope&&e._rememberPendingScope(t.scope),!(0,a.localStorageAvailable)()&&"remotestorage"===e.backend){t.redirectUri+=t.redirectUri.indexOf("#")>0?"&":"#";const r={userAddress:e.remote.userAddress,href:e.remote.href,storageApi:e.remote.storageApi,properties:e.remote.properties};t.redirectUri+="rsDiscovery="+(0,a.toBase64)(JSON.stringify(r))}const r=function(e){const t=new URL(e.redirectUri);e.state||(e.state=t.hash?t.hash.substring(1):""),e.response_type||(e.response_type="token");const r=new URL(e.authURL);r.searchParams.set("redirect_uri",e.redirectUri.replace(/#.*$/,"")),r.searchParams.set("scope",e.scope),r.searchParams.set("client_id",e.clientId);for(const n of["state","response_type","code_challenge","code_challenge_method","token_access_type"]){const t=e[n];t&&r.searchParams.set(n,t)}return r.href}(t);a.globalContext.cordova&&(d.getLocation().href.startsWith("file:")||"string"==typeof o.default.cordovaRedirectUri&&t.redirectUri===o.default.cordovaRedirectUri)?d.openWindow(r,t.redirectUri,"location=yes,clearsessioncache=yes,clearcache=yes").then(r=>{e.remote.configure({token:r.access_token}),"function"==typeof e._completeAuthorization&&e._completeAuthorization(r.scope||t.scope)}):d.setLocation(r)}static refreshAccessToken(e,t,r){return n(this,void 0,void 0,function*(){var e,n,i;yield t.configure({token:null,tokenType:null});const o=new URLSearchParams({grant_type:"refresh_token",client_id:t.clientId,refresh_token:r}),a=yield(Line truncated
|
||||
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||
Object.defineProperty(e,"__esModule",{value:!0}),e.bytes=e.stringToBytes=e.str=e.bytesToString=e.hex=e.utf8=e.bech32m=e.bech32=e.base58check=e.base58xmr=e.base58xrp=e.base58flickr=e.base58=e.base64url=e.base64=e.base32crockford=e.base32hex=e.base32=e.base16=e.utils=e.assertNumber=void 0,e.assertNumber=t;const l=(e,t)=>t?l(t,e%t):e,c=(e,t)=>e+(t-l(e,t));function u(e,r,n,i){if(!Array.isArray(e))throw new Error("convertRadix2: data should be array");if(r<=0||r>32)throw new Error(`convertRadix2: wrong from=${r}`);if(n<=0||n>32)throw new Error(`convertRadix2: wrong to=${n}`);if(c(r,n)>32)throw new Error(`convertRadix2: carry overflow from=${r} to=${n} carryBits=${c(r,n)}`);let o=0,s=0;const a=2**n-1,l=[];for(const c of e){if(t(c),c>=2**r)throw new Error(`convertRadix2: invalid data word=${c} from=${r}`);if(o=o<<r|c,s+r>32)throw new Error(`convertRadix2: carry overflow pos=${s} from=${r}`);for(s+=r;s>=n;s-=n)l.push((o>>s-n&a)>>>0);o&=2**s-1}if(o=o<<n-s&a,!i&&s>=r)throw new Error("Excess padding");if(!i&&o)throw new Error(`Non-zero padding: ${o}`);return i&&s>0&&l.push(o>>>0),l}function h(e){return t(e),{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix.encode input should be Uint8Array");return a(Array.from(t),256,e)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix.decode input should be array of strings");return Uint8Array.from(a(t,e,256))}}}function d(e,r=!1){if(t(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(c(8,e)>32||c(e,8)>32)throw new Error("radix2: carry overflow");return{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix2.encode input should be Uint8Array");return u(Array.from(t),8,e,!r)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix2.decode input should be array of strings");return Uint8Array.from(u(t,e,8,r))}}}function p(e){if("function"!=typeof e)throw new Error("unsafeWrapper fn should be function");return function(...t){try{return e.apply(null,t)}catch(r){}}}function f(e,r){if(t(e),"function"!=typeof r)throw new Error("checksum fn should be function");return{encode(t){if(!(t instanceof Uint8Array))throw new Error("checksum.encode: input should be Uint8Array");const n=r(t).slice(0,e),i=new Uint8Array(t.length+e);return i.set(t),i.set(n,t.length),i},decode(t){if(!(t instanceof Uint8Array))throw new Error("checksum.decode: input should be Uint8Array");const n=t.slice(0,-e),i=r(n).slice(0,e),o=t.slice(-e);for(let r=0;r<e;r++)if(i[r]!==o[r])throw new Error("Invalid checksum");return n}}}e.utils={alphabet:n,chain:r,checksum:f,radix:h,radix2:d,join:i,padding:o},e.base16=r(d(4),n("0123456789ABCDEF"),i("")),e.base32=r(d(5),n("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),o(5),i("")),e.base32hex=r(d(5),n("0123456789ABCDEFGHIJKLMNOPQRSTUV"),o(5),i("")),e.base32crockford=r(d(5),n("0123456789ABCDEFGHJKMNPQRSTVWXYZ"),i(""),s(e=>e.toUpperCase().replace(/O/g,"0").replace(/[IL]/g,"1"))),e.base64=r(d(6),n("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),o(6),i("")),e.base64url=r(d(6),n("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"),o(6),i(""));const g=e=>r(h(58),n(e),i(""));e.base58=g("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),e.base58flickr=g("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"),e.base58xrp=g("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz");const m=[0,2,3,5,6,7,9,10,11];e.base58xmr={encode(t){let r="";for(let n=0;n<t.length;n+=8){const i=t.subarray(n,n+8);r+=e.base58.encode(i).padStart(m[i.length],"1")}return r},decode(t){let r=[];for(let n=0;n<t.length;n+=11){const i=t.slice(n,n+11),o=m.indexOf(i.length),s=e.base58.decode(i);for(let e=0;e<s.length-o;e++)if(0!==s[e])throw new Error("base58xmr: wrong padding");r=r.concat(Array.from(s.slice(s.length-o)))}return Uint8Array.from(r)}},e.base58check=t=>r(f(4,e=>t(t(e))),e.base58);const y=r(n("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),i("")),b=[996825010,642813549,513874426,1027748829,705979059];function v(e){const t=e>>25;let r=(33554431&e)<<5;for(let n=0;n<b.length;n++)1==(t>>n&1)&&(r^=b[n]);return r}function _(e,t,r=1){const n=e.length;let i=1;for(let o=0;o<n;o++){const t=e.charCodeAt(o);if(t<33||t>126)throw new Error(`Invalid prefix (${e})`);i=v(i)^t>>5}i=v(i);for(let o=0;o<n;o++)i=v(i)^31&e.charCodeAt(o);for(let o of t)i=v(i)^o;for(let o=0;o<6;o++)i=v(i);return i^=r,y.encode(u([i%2**30],30,5,!1))}function E(e){const t="bech32"===e?1:734539939,r=d(5),n=r.decode,i=r.encode,o=p(n);function s(e,r=90){if("string"!=typeof e)throw new Error("bech32.decode input should be string, not "+typeof e);if(e.length<8||!1!==r&&e.length>r)throw new TypeError(`Wrong string length: ${e.length} (${e}). Expected (8..${r})`);const n=e.toLowerCase();if(e!==n&&e!==e.toUpperCase())throw new Error("String must be lowercase or uppercase");const i=(e=n).lastIndexOf("1");if(0===i||-1===i)throw new Error('Letter "1" must be present between prefix and data only');const o=e.slice(0,i),s=e.slLine truncated
|
||||
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function isBytes$2(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function anumber$2(e,t=""){if(!Number.isSafeInteger(e)||e<0)throw new Error(`${t&&`"${t}" `}expected integer >= 0, got ${e}`)}function abytes$2(e,t,r=""){const n=isBytes$2(e),i=e?.length,o=void 0!==t;if(!n||o&&i!==t)throw new Error((r&&`"${r}" `)+"expected Uint8Array"+(o?` of length ${t}`:"")+", got "+(n?`length=${i}`:"type="+typeof e));return e}function ahash(e){if("function"!=typeof e||"function"!=typeof e.create)throw new Error("Hash must wrapped by utils.createHasher");anumber$2(e.outputLen),anumber$2(e.blockLen)}function aexists(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function aoutput(e,t){abytes$2(e,void 0,"digestInto() output");const r=t.outputLen;if(e.length<r)throw new Error('"digestInto() output" expected to be of length >='+r)}function clean$1(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function createView(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function rotr(e,t){return e<<32-t|e>>>t}const hasHexBuiltin=(()=>"function"==typeof Uint8Array.from([]).toHex&&"function"==typeof Uint8Array.fromHex)(),hexes=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex(e){if(abytes$2(e),hasHexBuiltin)return e.toHex();let t="";for(let r=0;r<e.length;r++)t+=hexes[e[r]];return t}const asciis={_0:48,_9:57,A:65,F:70,a:97,f:102};function asciiToBase16(e){return e>=asciis._0&&e<=asciis._9?e-asciis._0:e>=asciis.A&&e<=asciis.F?e-(asciis.A-10):e>=asciis.a&&e<=asciis.f?e-(asciis.a-10):void 0}function hexToBytes(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);if(hasHexBuiltin)return Uint8Array.fromHex(e);const t=e.length,r=t/2;if(t%2)throw new Error("hex string expected, got unpadded hex of length "+t);const n=new Uint8Array(r);for(let i=0,o=0;i<r;i++,o+=2){const t=asciiToBase16(e.charCodeAt(o)),r=asciiToBase16(e.charCodeAt(o+1));if(void 0===t||void 0===r){const t=e[o]+e[o+1];throw new Error('hex string expected, got non-hex character "'+t+'" at index '+o)}n[i]=16*t+r}return n}function concatBytes(...e){let t=0;for(let n=0;n<e.length;n++){const r=e[n];abytes$2(r),t+=r.length}const r=new Uint8Array(t);for(let n=0,i=0;n<e.length;n++){const t=e[n];r.set(t,i),i+=t.length}return r}function createHasher(e,t={}){const r=(t,r)=>e(r).update(t).digest(),n=e(void 0);return r.outputLen=n.outputLen,r.blockLen=n.blockLen,r.create=t=>e(t),Object.assign(r,t),Object.freeze(r)}function randomBytes(e=32){const t="object"==typeof globalThis?globalThis.crypto:null;if("function"!=typeof t?.getRandomValues)throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}const oidNist=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function Chi(e,t,r){return e&t^~e&r}function Maj(e,t,r){return e&t^e&r^t&r}class HashMD{blockLen;outputLen;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(e,t,r,n){this.blockLen=e,this.outputLen=t,this.padOffset=r,this.isLE=n,this.buffer=new Uint8Array(e),this.view=createView(this.buffer)}update(e){aexists(this),abytes$2(e);const{view:t,buffer:r,blockLen:n}=this,i=e.length;for(let o=0;o<i;){const s=Math.min(n-this.pos,i-o);if(s===n){const t=createView(e);for(;n<=i-o;o+=n)this.process(t,o);continue}r.set(e.subarray(o,o+s),this.pos),this.pos+=s,o+=s,this.pos===n&&(this.process(t,0),this.pos=0)}return this.length+=e.length,this.roundClean(),this}digestInto(e){aexists(this),aoutput(e,this),this.finished=!0;const{buffer:t,view:r,blockLen:n,isLE:i}=this;let{pos:o}=this;t[o++]=128,clean$1(this.buffer.subarray(o)),this.padOffset>n-o&&(this.process(r,0),o=0);for(let u=o;u<n;u++)t[u]=0;r.setBigUint64(n-8,BigInt(8*this.length),i),this.process(r,0);const s=createView(e),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen must be aligned to 32bit");const l=a/4,c=this.get();if(l>c.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u<l;u++)s.setUint32(4*u,c[u],i)}digest(){const{buffer:e,outputLen:t}=this;this.digestInto(e);const r=e.slice(0,t);return this.destroy(),r}_cloneInto(e){e||=new this.constructor,e.set(...this.get());const{blockLen:t,buffer:r,length:n,finished:i,destroyed:o,pos:s}=this;return e.destroyed=o,e.finished=i,e.length=n,e.pos=s,n%t&&e.buffer.set(r),e}clone(){return this._cloneInto()}}const SHA256_IV=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),SHA256_K=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,358452871Line truncated
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function abool$1(e,t=""){if("boolean"!=typeof e)throw new Error((t&&`"${t}" `)+"expected boolean, got type="+typeof e);return e}function abignumber(e){if("bigint"==typeof e){if(!isPosBig(e))throw new Error("positive bigint expected, got "+e)}else anumber$2(e);return e}function numberToHexUnpadded(e){const t=abignumber(e).toString(16);return 1&t.length?"0"+t:t}function hexToNumber(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);return""===e?_0n$4:BigInt("0x"+e)}function bytesToNumberBE(e){return hexToNumber(bytesToHex(e))}function bytesToNumberLE(e){return hexToNumber(bytesToHex(copyBytes$1(abytes$2(e)).reverse()))}function numberToBytesBE(e,t){anumber$2(t);const r=hexToBytes((e=abignumber(e)).toString(16).padStart(2*t,"0"));if(r.length!==t)throw new Error("number too large");return r}function numberToBytesLE(e,t){return numberToBytesBE(e,t).reverse()}function copyBytes$1(e){return Uint8Array.from(e)}function asciiToBytes(e){return Uint8Array.from(e,(t,r)=>{const n=t.charCodeAt(0);if(1!==t.length||n>127)throw new Error(`string contains non-ASCII character "${e[r]}" with code ${n} at position ${r}`);return n})}const isPosBig=e=>"bigint"==typeof e&&_0n$4<=e;function inRange(e,t,r){return isPosBig(e)&&isPosBig(t)&&isPosBig(r)&&t<=e&&e<r}function aInRange(e,t,r,n){if(!inRange(t,r,n))throw new Error("expected valid "+e+": "+r+" <= n < "+n+", got "+t)}function bitLen(e){let t;for(t=0;e>_0n$4;e>>=_1n$3,t+=1);return t}const bitMask=e=>(_1n$3<<BigInt(e))-_1n$3;function createHmacDrbg(e,t,r){if(anumber$2(e,"hashLen"),anumber$2(t,"qByteLen"),"function"!=typeof r)throw new Error("hmacFn must be a function");const n=e=>new Uint8Array(e),i=Uint8Array.of(),o=Uint8Array.of(0),s=Uint8Array.of(1);let a=n(e),l=n(e),c=0;const u=()=>{a.fill(1),l.fill(0),c=0},h=(...e)=>r(l,concatBytes(a,...e)),d=(e=i)=>{l=h(o,e),a=h(),0!==e.length&&(l=h(s,e),a=h())},p=()=>{if(c++>=1e3)throw new Error("drbg: tried max amount of iterations");let e=0;const r=[];for(;e<t;){a=h();const t=a.slice();r.push(t),e+=a.length}return concatBytes(...r)};return(e,t)=>{let r;for(u(),d(e);!(r=t(p()));)d();return u(),r}}function validateObject(e,t={},r={}){if(!e||"object"!=typeof e)throw new Error("expected valid options object");const n=(t,r)=>Object.entries(t).forEach(([t,n])=>function(t,r,n){const i=e[t];if(n&&void 0===i)return;const o=typeof i;if(o!==r||null===i)throw new Error(`param "${t}" is invalid: expected ${r}, got ${o}`)}(t,n,r));n(t,!1),n(r,!0)}function memoized(e){const t=new WeakMap;return(r,...n)=>{const i=t.get(r);if(void 0!==i)return i;const o=e(r,...n);return t.set(r,o),o}}
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$3=BigInt(0),_1n$2=BigInt(1),_2n$2=BigInt(2),_3n$1=BigInt(3),_4n$1=BigInt(4),_5n=BigInt(5),_7n=BigInt(7),_8n=BigInt(8),_9n=BigInt(9),_16n=BigInt(16);function mod(e,t){const r=e%t;return r>=_0n$3?r:t+r}function pow2(e,t,r){let n=e;for(;t-- >_0n$3;)n*=n,n%=r;return n}function invert(e,t){if(e===_0n$3)throw new Error("invert: expected non-zero number");if(t<=_0n$3)throw new Error("invert: expected positive modulus, got "+t);let r=mod(e,t),n=t,i=_0n$3,o=_1n$2;for(;r!==_0n$3;){const e=n%r,t=i-o*(n/r);n=r,r=e,i=o,o=t}if(n!==_1n$2)throw new Error("invert: does not exist");return mod(i,t)}function assertIsSquare(e,t,r){if(!e.eql(e.sqr(t),r))throw new Error("Cannot find square root")}function sqrt3mod4(e,t){const r=(e.ORDER+_1n$2)/_4n$1,n=e.pow(t,r);return assertIsSquare(e,n,t),n}function sqrt5mod8(e,t){const r=(e.ORDER-_5n)/_8n,n=e.mul(t,_2n$2),i=e.pow(n,r),o=e.mul(t,i),s=e.mul(e.mul(o,_2n$2),i),a=e.mul(o,e.sub(s,e.ONE));return assertIsSquare(e,a,t),a}function sqrt9mod16(e){const t=Field(e),r=tonelliShanks(e),n=r(t,t.neg(t.ONE)),i=r(t,n),o=r(t,t.neg(n)),s=(e+_7n)/_16n;return(e,t)=>{let r=e.pow(t,s),a=e.mul(r,n);const l=e.mul(r,i),c=e.mul(r,o),u=e.eql(e.sqr(a),t),h=e.eql(e.sqr(l),t);r=e.cmov(r,a,u),a=e.cmov(c,l,h);const d=e.eql(e.sqr(a),t),p=e.cmov(r,a,d);return assertIsSquare(e,p,t),p}}function tonelliShanks(e){if(e<_3n$1)throw new Error("sqrt is not defined for small field");let t=e-_1n$2,r=0;for(;t%_2n$2===_0n$3;)t/=_2n$2,r++;let n=_2n$2;const i=Field(e);for(;1===FpLegendre(i,n);)if(n++>1e3)throw new Error("Cannot find square root: probably non-prime P");if(1===r)return sqrt3mod4;let o=i.pow(n,t);const s=(t+_1n$2)/_2n$2;return function(e,n){if(e.is0(n))return n;if(1!==FpLegendre(e,n))throw new Error("Cannot find square root");let i=r,a=e.mul(e.ONE,o),l=e.pow(n,t),c=e.pow(n,s);for(;!e.eql(l,e.ONE);){if(e.is0(l))return e.ZERO;let t=1,r=e.sqr(l);for(;!e.eql(r,e.ONE);)if(t++,r=e.sqr(r),t===i)throw new Error("Cannot find square root");const n=_1n$2<<BigInt(i-t-1),o=e.pow(a,n);i=t,a=e.sqr(o),l=e.mul(l,a),c=e.mul(c,o)}return c}}function FpSqrt(e){return e%_4n$1===_3n$1?sqrt3mod4:e%_8n===_5n?sqrt5mod8:e%_16n===_9n?sqrt9mod16(e):tonelliShanks(e)}const FIELD_FIELDS=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function validateField(e){const t=FIELD_FIELDS.reduce((e,t)=>(e[t]="function",e),{ORDER:"bigint",BYTES:"number",BITS:"number"});return validateObject(e,t),e}function FpPow(e,t,r){if(r<_0n$3)throw new Error("invalid exponent, negatives unsupported");if(r===_0n$3)return e.ONE;if(r===_1n$2)return t;let n=e.ONE,i=t;for(;r>_0n$3;)r&_1n$2&&(n=e.mul(n,i)),i=e.sqr(i),r>>=_1n$2;return n}function FpInvertBatch(e,t,r=!1){const n=new Array(t.length).fill(r?e.ZERO:void 0),i=t.reduce((t,r,i)=>e.is0(r)?t:(n[i]=t,e.mul(t,r)),e.ONE),o=e.inv(i);return t.reduceRight((t,r,i)=>e.is0(r)?t:(n[i]=e.mul(t,n[i]),e.mul(t,r)),o),n}function FpLegendre(e,t){const r=(e.ORDER-_1n$2)/_2n$2,n=e.pow(t,r),i=e.eql(n,e.ONE),o=e.eql(n,e.ZERO),s=e.eql(n,e.neg(e.ONE));if(!i&&!o&&!s)throw new Error("invalid Legendre symbol result");return i?1:o?0:-1}function nLength(e,t){void 0!==t&&anumber$2(t);const r=void 0!==t?t:e.toString(2).length;return{nBitLength:r,nByteLength:Math.ceil(r/8)}}class _Field{ORDER;BITS;BYTES;isLE;ZERO=_0n$3;ONE=_1n$2;_lengths;_sqrt;_mod;constructor(e,t={}){if(e<=_0n$3)throw new Error("invalid field: expected ORDER > 0, got "+e);let r;this.isLE=!1,null!=t&&"object"==typeof t&&("number"==typeof t.BITS&&(r=t.BITS),"function"==typeof t.sqrt&&(this.sqrt=t.sqrt),"boolean"==typeof t.isLE&&(this.isLE=t.isLE),t.allowedLengths&&(this._lengths=t.allowedLengths?.slice()),"boolean"==typeof t.modFromBytes&&(this._mod=t.modFromBytes));const{nBitLength:n,nByteLength:i}=nLength(e,r);if(i>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");this.ORDER=e,this.BITS=n,this.BYTES=i,this._sqrt=void 0,Object.preventExtensions(this)}create(e){return mod(e,this.ORDER)}isValid(e){if("bigint"!=typeof e)throw new Error("invalid field element: expected bigint, got "+typeof e);return _0n$3<=e&&e<this.ORDER}is0(e){return e===_0n$3}isValidNot0(e){return!this.is0(e)&&this.isValid(e)}isOdd(e){return(e&_1n$2)===_1n$2}neg(e){return mod(-e,this.ORDER)}eql(e,t){return e===t}sqr(e){return mod(e*e,this.ORDER)}add(e,t){return mod(e+t,this.ORDER)}sub(e,t){return mod(e-t,this.ORDER)}mul(e,t){return mod(e*t,this.ORDER)}pow(e,t){return FpPow(this,e,t)}div(e,t){return mod(e*invert(t,this.ORDER),this.ORDER)}sqrN(e){return e*e}addN(e,t){return e+t}subN(e,t){return e-t}mulN(e,t){return e*t}inv(e){return invert(e,this.ORDER)}sqrt(e){return this._sqrt||(this._sqrt=FpSqrt(this.ORDER)),this._sqrt(this,e)}toBytes(e){return this.isLE?numberToBytesLE(e,this.BYTES):numberToBytesBE(e,this.BYTES)}fromBytes(e,t=!1){abytes$2(e);const{_lengths:r,BYTES:n,isLE:i,ORDER:o,_mod:s}=this;if(r){if(!r.includes(e.length)||e.length>n)throw new
Line truncated
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$2=BigInt(0),_1n$1=BigInt(1);function negateCt(e,t){const r=t.negate();return e?r:t}function normalizeZ(e,t){const r=FpInvertBatch(e.Fp,t.map(e=>e.Z));return t.map((t,n)=>e.fromAffine(t.toAffine(r[n])))}function validateW(e,t){if(!Number.isSafeInteger(e)||e<=0||e>t)throw new Error("invalid window size, expected [1.."+t+"], got W="+e)}function calcWOpts(e,t){validateW(e,t);const r=2**e;return{windows:Math.ceil(t/e)+1,windowSize:2**(e-1),mask:bitMask(e),maxNumber:r,shiftBy:BigInt(e)}}function calcOffsets(e,t,r){const{windowSize:n,mask:i,maxNumber:o,shiftBy:s}=r;let a=Number(e&i),l=e>>s;a>n&&(a-=o,l+=_1n$1);const c=t*n;return{nextN:l,offset:c+Math.abs(a)-1,isZero:0===a,isNeg:a<0,isNegF:t%2!=0,offsetF:c}}const pointPrecomputes=new WeakMap,pointWindowSizes=new WeakMap;function getW(e){return pointWindowSizes.get(e)||1}function assert0(e){if(e!==_0n$2)throw new Error("invalid wNAF")}class wNAF{BASE;ZERO;Fn;bits;constructor(e,t){this.BASE=e.BASE,this.ZERO=e.ZERO,this.Fn=e.Fn,this.bits=t}_unsafeLadder(e,t,r=this.ZERO){let n=e;for(;t>_0n$2;)t&_1n$1&&(r=r.add(n)),n=n.double(),t>>=_1n$1;return r}precomputeWindow(e,t){const{windows:r,windowSize:n}=calcWOpts(t,this.bits),i=[];let o=e,s=o;for(let a=0;a<r;a++){s=o,i.push(s);for(let e=1;e<n;e++)s=s.add(o),i.push(s);o=s.double()}return i}wNAF(e,t,r){if(!this.Fn.isValid(r))throw new Error("invalid scalar");let n=this.ZERO,i=this.BASE;const o=calcWOpts(e,this.bits);for(let s=0;s<o.windows;s++){const{nextN:e,offset:a,isZero:l,isNeg:c,isNegF:u,offsetF:h}=calcOffsets(r,s,o);r=e,l?i=i.add(negateCt(u,t[h])):n=n.add(negateCt(c,t[a]))}return assert0(r),{p:n,f:i}}wNAFUnsafe(e,t,r,n=this.ZERO){const i=calcWOpts(e,this.bits);for(let o=0;o<i.windows&&r!==_0n$2;o++){const{nextN:e,offset:s,isZero:a,isNeg:l}=calcOffsets(r,o,i);if(r=e,!a){const e=t[s];n=n.add(l?e.negate():e)}}return assert0(r),n}getPrecomputes(e,t,r){let n=pointPrecomputes.get(t);return n||(n=this.precomputeWindow(t,e),1!==e&&("function"==typeof r&&(n=r(n)),pointPrecomputes.set(t,n))),n}cached(e,t,r){const n=getW(e);return this.wNAF(n,this.getPrecomputes(n,e,r),t)}unsafe(e,t,r,n){const i=getW(e);return 1===i?this._unsafeLadder(e,t,n):this.wNAFUnsafe(i,this.getPrecomputes(i,e,r),t,n)}createCache(e,t){validateW(t,this.bits),pointWindowSizes.set(e,t),pointPrecomputes.delete(e)}hasCache(e){return 1!==getW(e)}}function mulEndoUnsafe(e,t,r,n){let i=t,o=e.ZERO,s=e.ZERO;for(;r>_0n$2||n>_0n$2;)r&_1n$1&&(o=o.add(i)),n&_1n$1&&(s=s.add(i)),i=i.double(),r>>=_1n$1,n>>=_1n$1;return{p1:o,p2:s}}function createField(e,t,r){if(t){if(t.ORDER!==e)throw new Error("Field.ORDER must match order: Fp == p, Fn == n");return validateField(t),t}return Field(e,{isLE:r})}function createCurveFields(e,t,r={},n){if(void 0===n&&(n="edwards"===e),!t||"object"!=typeof t)throw new Error(`expected valid ${e} CURVE object`);for(const a of["p","n","h"]){const e=t[a];if(!("bigint"==typeof e&&e>_0n$2))throw new Error(`CURVE.${a} must be positive bigint`)}const i=createField(t.p,r.Fp,n),o=createField(t.n,r.Fn,n),s=["Gx","Gy","a","b"];for(const a of s)if(!i.isValid(t[a]))throw new Error(`CURVE.${a} must be valid field element of CURVE.Fp`);return{CURVE:t=Object.freeze(Object.assign({},t)),Fp:i,Fn:o}}function createKeygen(e,t){return function(r){const n=e(r);return{secretKey:n,publicKey:t(n)}}}class _HMAC{oHash;iHash;blockLen;outputLen;finished=!1;destroyed=!1;constructor(e,t){if(ahash(e),abytes$2(t,void 0,"key"),this.iHash=e.create(),"function"!=typeof this.iHash.update)throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;const r=this.blockLen,n=new Uint8Array(r);n.set(t.length>r?e.create().update(t).digest():t);for(let i=0;i<n.length;i++)n[i]^=54;this.iHash.update(n),this.oHash=e.create();for(let i=0;i<n.length;i++)n[i]^=106;this.oHash.update(n),clean$1(n)}update(e){return aexists(this),this.iHash.update(e),this}digestInto(e){aexists(this),abytes$2(e,this.outputLen,"output"),this.finished=!0,this.iHash.digestInto(e),this.oHash.update(e),this.oHash.digestInto(e),this.destroy()}digest(){const e=new Uint8Array(this.oHash.outputLen);return this.digestInto(e),e}_cloneInto(e){e||=Object.create(Object.getPrototypeOf(this),{});const{oHash:t,iHash:r,finished:n,destroyed:i,blockLen:o,outputLen:s}=this;return e.finished=n,e.destroyed=i,e.blockLen=o,e.outputLen=s,e.oHash=t._cloneInto(e.oHash),e.iHash=r._cloneInto(e.iHash),e}clone(){return this._cloneInto()}destroy(){this.destroyed=!0,this.oHash.destroy(),this.iHash.destroy()}}const hmac=(e,t,r)=>new _HMAC(e,t).update(r).digest();hmac.create=(e,t)=>new _HMAC(e,t);
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||
const divNearest=(e,t)=>(e+(e>=0?t:-t)/_2n$1)/t;function _splitEndoScalar(e,t,r){const[[n,i],[o,s]]=t,a=divNearest(s*e,r),l=divNearest(-i*e,r);let c=e-a*n-l*o,u=-a*i-l*s;const h=c<_0n$1,d=u<_0n$1;h&&(c=-c),d&&(u=-u);const p=bitMask(Math.ceil(bitLen(r)/2))+_1n;if(c<_0n$1||c>=p||u<_0n$1||u>=p)throw new Error("splitScalar (endomorphism): failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}function validateSigFormat(e){if(!["compact","recovered","der"].includes(e))throw new Error('Signature format must be "compact", "recovered", or "der"');return e}function validateSigOpts(e,t){const r={};for(let n of Object.keys(t))r[n]=void 0===e[n]?t[n]:e[n];return abool$1(r.lowS,"lowS"),abool$1(r.prehash,"prehash"),void 0!==r.format&&validateSigFormat(r.format),r}class DERErr extends Error{constructor(e=""){super(e)}}const DER={Err:DERErr,_tlv:{encode:(e,t)=>{const{Err:r}=DER;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(1&t.length)throw new r("tlv.encode: unpadded data");const n=t.length/2,i=numberToHexUnpadded(n);if(i.length/2&128)throw new r("tlv.encode: long form length too big");const o=n>127?numberToHexUnpadded(i.length/2|128):"";return numberToHexUnpadded(e)+o+i+t},decode(e,t){const{Err:r}=DER;let n=0;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(t.length<2||t[n++]!==e)throw new r("tlv.decode: wrong tlv");const i=t[n++];let o=0;if(128&i){const e=127&i;if(!e)throw new r("tlv.decode(long): indefinite length not supported");if(e>4)throw new r("tlv.decode(long): byte length is too big");const s=t.subarray(n,n+e);if(s.length!==e)throw new r("tlv.decode: length bytes not complete");if(0===s[0])throw new r("tlv.decode(long): zero leftmost byte");for(const t of s)o=o<<8|t;if(n+=e,o<128)throw new r("tlv.decode(long): not minimal encoding")}else o=i;const s=t.subarray(n,n+o);if(s.length!==o)throw new r("tlv.decode: wrong value length");return{v:s,l:t.subarray(n+o)}}},_int:{encode(e){const{Err:t}=DER;if(e<_0n$1)throw new t("integer: negative integers are not allowed");let r=numberToHexUnpadded(e);if(8&Number.parseInt(r[0],16)&&(r="00"+r),1&r.length)throw new t("unexpected DER parsing assertion: unpadded hex");return r},decode(e){const{Err:t}=DER;if(128&e[0])throw new t("invalid signature integer: negative");if(0===e[0]&&!(128&e[1]))throw new t("invalid signature integer: unnecessary leading zero");return bytesToNumberBE(e)}},toSig(e){const{Err:t,_int:r,_tlv:n}=DER,i=abytes$2(e,void 0,"signature"),{v:o,l:s}=n.decode(48,i);if(s.length)throw new t("invalid signature: left bytes after parsing");const{v:a,l:l}=n.decode(2,o),{v:c,l:u}=n.decode(2,l);if(u.length)throw new t("invalid signature: left bytes after parsing");return{r:r.decode(a),s:r.decode(c)}},hexFromSig(e){const{_tlv:t,_int:r}=DER,n=t.encode(2,r.encode(e.r))+t.encode(2,r.encode(e.s));return t.encode(48,n)}},_0n$1=BigInt(0),_1n=BigInt(1),_2n$1=BigInt(2),_3n=BigInt(3),_4n=BigInt(4);function weierstrass(e,t={}){const r=createCurveFields("weierstrass",e,t),{Fp:n,Fn:i}=r;let o=r.CURVE;const{h:s,n:a}=o;validateObject(t,{},{allowInfinityPoint:"boolean",clearCofactor:"function",isTorsionFree:"function",fromBytes:"function",toBytes:"function",endo:"object"});const{endo:l}=t;if(l&&(!n.is0(o.a)||"bigint"!=typeof l.beta||!Array.isArray(l.basises)))throw new Error('invalid endo: expected "beta": bigint and "basises": array');const c=getWLengths(n,i);function u(){if(!n.isOdd)throw new Error("compression is not supported: Field does not have .isOdd()")}const h=t.toBytes||function(e,t,r){const{x:i,y:o}=t.toAffine(),s=n.toBytes(i);return abool$1(r,"isCompressed"),r?(u(),concatBytes(pprefix(!n.isOdd(o)),s)):concatBytes(Uint8Array.of(4),s,n.toBytes(o))},d=t.fromBytes||function(e){abytes$2(e,void 0,"Point");const{publicKey:t,publicKeyUncompressed:r}=c,i=e.length,o=e[0],s=e.subarray(1);if(i!==t||2!==o&&3!==o){if(i===r&&4===o){const e=n.BYTES,t=n.fromBytes(s.subarray(0,e)),r=n.fromBytes(s.subarray(e,2*e));if(!f(t,r))throw new Error("bad point: is not on curve");return{x:t,y:r}}throw new Error(`bad point: got length ${i}, expected compressed=${t} or uncompressed=${r}`)}{const e=n.fromBytes(s);if(!n.isValid(e))throw new Error("bad point: is not on curve, wrong x");const t=p(e);let r;try{r=n.sqrt(t)}catch(a){const e=a instanceof Error?": "+a.message:"";throw new Error("bad point: is not on curve, sqrt error"+e)}return u(),!(1&~o)!==n.isOdd(r)&&(r=n.neg(r)),{x:e,y:r}}};function p(e){const t=n.sqr(e),r=n.mul(t,e);return n.add(n.add(r,n.mul(e,o.a)),o.b)}function f(e,t){const r=n.sqr(t),i=p(e);return n.eql(r,i)}if(!f(o.Gx,o.Gy))throw new Error("bad curve params: generator point");const g=n.mul(n.pow(o.a,_3n),_4n),m=n.mul(n.sqr(o.b),BigInt(27));if(n.is0(n.add(g,m)))throw new Error("bad curve params: a or b");function y(e,t,r=!1){if(!n.isValid(t)||r&&n.is0(t))throw new Error(`bad point coordinate ${e}`);return t}function b(e){if(!(e instanceof C))throw new Error("Weierstrass Point expected")}function v(e){if(!l||!l.basises)throw new Error("no endo");return _splitEndoScalar(e,l.basises,i.OLine truncated
|
||||
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const secp256k1_CURVE={p:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:BigInt(1),a:BigInt(0),b:BigInt(7),Gx:BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),Gy:BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")},secp256k1_ENDO={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),basises:[[BigInt("0x3086d221a7d46bcde86c90e49284eb15"),-BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],[BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),BigInt("0x3086d221a7d46bcde86c90e49284eb15")]]},_0n=BigInt(0),_2n=BigInt(2);function sqrtMod(e){const t=secp256k1_CURVE.p,r=BigInt(3),n=BigInt(6),i=BigInt(11),o=BigInt(22),s=BigInt(23),a=BigInt(44),l=BigInt(88),c=e*e*e%t,u=c*c*e%t,h=pow2(u,r,t)*u%t,d=pow2(h,r,t)*u%t,p=pow2(d,_2n,t)*c%t,f=pow2(p,i,t)*p%t,g=pow2(f,o,t)*f%t,m=pow2(g,a,t)*g%t,y=pow2(m,l,t)*m%t,b=pow2(y,a,t)*g%t,v=pow2(b,r,t)*u%t,_=pow2(v,s,t)*f%t,E=pow2(_,n,t)*c%t,w=pow2(E,_2n,t);if(!Fpk1.eql(Fpk1.sqr(w),e))throw new Error("Cannot find square root");return w}const Fpk1=Field(secp256k1_CURVE.p,{sqrt:sqrtMod}),Pointk1=weierstrass(secp256k1_CURVE,{Fp:Fpk1,endo:secp256k1_ENDO}),secp256k1=ecdsa(Pointk1,sha256),TAGGED_HASH_PREFIXES={};function taggedHash(e,...t){let r=TAGGED_HASH_PREFIXES[e];if(void 0===r){const t=sha256(asciiToBytes(e));r=concatBytes(t,t),TAGGED_HASH_PREFIXES[e]=r}return sha256(concatBytes(r,...t))}const pointToBytes=e=>e.toBytes(!0).slice(1),hasEven=e=>e%_2n===_0n;function schnorrGetExtPubKey(e){const{Fn:t,BASE:r}=Pointk1,n=t.fromBytes(e),i=r.multiply(n);return{scalar:hasEven(i.y)?n:t.neg(n),bytes:pointToBytes(i)}}function lift_x(e){const t=Fpk1;if(!t.isValidNot0(e))throw new Error("invalid x: Fail if x ≥ p");const r=t.create(e*e),n=t.create(r*e+BigInt(7));let i=t.sqrt(n);hasEven(i)||(i=t.neg(i));const o=Pointk1.fromAffine({x:e,y:i});return o.assertValidity(),o}const num=bytesToNumberBE;function challenge(...e){return Pointk1.Fn.create(num(taggedHash("BIP0340/challenge",...e)))}function schnorrGetPublicKey(e){return schnorrGetExtPubKey(e).bytes}function schnorrSign(e,t,r=randomBytes(32)){const{Fn:n}=Pointk1,i=abytes$2(e,void 0,"message"),{bytes:o,scalar:s}=schnorrGetExtPubKey(t),a=abytes$2(r,32,"auxRand"),l=n.toBytes(s^num(taggedHash("BIP0340/aux",a))),c=taggedHash("BIP0340/nonce",l,o,i),{bytes:u,scalar:h}=schnorrGetExtPubKey(c),d=challenge(u,o,i),p=new Uint8Array(64);if(p.set(u,0),p.set(n.toBytes(n.create(h+d*s)),32),!schnorrVerify(p,i,o))throw new Error("sign: Invalid signature produced");return p}function schnorrVerify(e,t,r){const{Fp:n,Fn:i,BASE:o}=Pointk1,s=abytes$2(e,64,"signature"),a=abytes$2(t,void 0,"message"),l=abytes$2(r,32,"publicKey");try{const e=lift_x(num(l)),t=num(s.subarray(0,32));if(!n.isValidNot0(t))return!1;const r=num(s.subarray(32,64));if(!i.isValidNot0(r))return!1;const c=challenge(i.toBytes(t),pointToBytes(e),a),u=o.multiplyUnsafe(r).add(e.multiplyUnsafe(i.neg(c))),{x:h,y:d}=u.toAffine();return!(u.is0()||!hasEven(d)||h!==t)}catch(error){return!1}}const schnorr=(()=>{const e=(e=randomBytes(48))=>mapHashToField(e,secp256k1_CURVE.n);return{keygen:createKeygen(e,schnorrGetPublicKey),getPublicKey:schnorrGetPublicKey,sign:schnorrSign,verify:schnorrVerify,Point:Pointk1,utils:{randomSecretKey:e,taggedHash:taggedHash,lift_x:lift_x,pointToBytes:pointToBytes},lengths:{secretKey:32,publicKey:32,publicKeyHasPrefix:!1,signature:64,seed:48}}})();
|
||||
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */function isBytes$1(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function abytes$1(e){if(!isBytes$1(e))throw new Error("Uint8Array expected")}function isArrayOf(e,t){return!!Array.isArray(t)&&(0===t.length||(e?t.every(e=>"string"==typeof e):t.every(e=>Number.isSafeInteger(e))))}function afn(e){if("function"!=typeof e)throw new Error("function expected");return!0}function astr(e,t){if("string"!=typeof t)throw new Error(`${e}: string expected`);return!0}function anumber$1(e){if(!Number.isSafeInteger(e))throw new Error(`invalid integer: ${e}`)}function aArr(e){if(!Array.isArray(e))throw new Error("array expected")}function astrArr(e,t){if(!isArrayOf(!0,t))throw new Error(`${e}: array of strings expected`)}function anumArr(e,t){if(!isArrayOf(!1,t))throw new Error(`${e}: array of numbers expected`)}function chain(...e){const t=e=>e,r=(e,t)=>r=>e(t(r)),n=e.map(e=>e.encode).reduceRight(r,t),i=e.map(e=>e.decode).reduce(r,t);return{encode:n,decode:i}}function alphabet(e){const t="string"==typeof e?e.split(""):e,r=t.length;astrArr("alphabet",t);const n=new Map(t.map((e,t)=>[e,t]));return{encode:n=>(aArr(n),n.map(n=>{if(!Number.isSafeInteger(n)||n<0||n>=r)throw new Error(`alphabet.encode: digit index outside alphabet "${n}". Allowed: ${e}`);return t[n]})),decode:t=>(aArr(t),t.map(t=>{astr("alphabet.decode",t);const r=n.get(t);if(void 0===r)throw new Error(`Unknown letter: "${t}". Allowed: ${e}`);return r}))}}function join(e=""){return astr("join",e),{encode:t=>(astrArr("join.decode",t),t.join(e)),decode:t=>(astr("join.decode",t),t.split(e))}}function padding(e,t="="){return anumber$1(e),astr("padding",t),{encode(r){for(astrArr("padding.encode",r);r.length*e%8;)r.push(t);return r},decode(r){astrArr("padding.decode",r);let n=r.length;if(n*e%8)throw new Error("padding: invalid, string should have whole number of bytes");for(;n>0&&r[n-1]===t;n--)if((n-1)*e%8==0)throw new Error("padding: invalid, string has too much padding");return r.slice(0,n)}}}const gcd=(e,t)=>0===t?e:gcd(t,e%t),radix2carry=(e,t)=>e+(t-gcd(e,t)),powers=(()=>{let e=[];for(let t=0;t<40;t++)e.push(2**t);return e})();function convertRadix2(e,t,r,n){if(aArr(e),t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(r<=0||r>32)throw new Error(`convertRadix2: wrong to=${r}`);if(radix2carry(t,r)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${r} carryBits=${radix2carry(t,r)}`);let i=0,o=0;const s=powers[t],a=powers[r]-1,l=[];for(const c of e){if(anumber$1(c),c>=s)throw new Error(`convertRadix2: invalid data word=${c} from=${t}`);if(i=i<<t|c,o+t>32)throw new Error(`convertRadix2: carry overflow pos=${o} from=${t}`);for(o+=t;o>=r;o-=r)l.push((i>>o-r&a)>>>0);const e=powers[o];if(void 0===e)throw new Error("invalid carry");i&=e-1}if(i=i<<r-o&a,!n&&o>=t)throw new Error("Excess padding");if(!n&&i>0)throw new Error(`Non-zero padding: ${i}`);return n&&o>0&&l.push(i>>>0),l}function radix2(e,t=!1){if(anumber$1(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(radix2carry(8,e)>32||radix2carry(e,8)>32)throw new Error("radix2: carry overflow");return{encode:r=>{if(!isBytes$1(r))throw new Error("radix2.encode input should be Uint8Array");return convertRadix2(Array.from(r),8,e,!t)},decode:r=>(anumArr("radix2.decode",r),Uint8Array.from(convertRadix2(r,e,8,t)))}}function unsafeWrapper(e){return afn(e),function(...t){try{return e.apply(null,t)}catch(r){}}}const hasBase64Builtin=(()=>"function"==typeof Uint8Array.from([]).toBase64&&"function"==typeof Uint8Array.fromBase64)(),decodeBase64Builtin=e=>{if(astr("base64",e),e.length>0&&!/^[A-Za-z0-9=+/]+$/.test(e))throw new Error("invalid base64");return Uint8Array.fromBase64(e,{alphabet:"base64",lastChunkHandling:"strict"})},base64=hasBase64Builtin?{encode:e=>(abytes$1(e),e.toBase64()),decode:e=>decodeBase64Builtin(e)}:chain(radix2(6),alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),padding(6),join("")),BECH_ALPHABET=chain(alphabet("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),join("")),POLYMOD_GENERATORS=[996825010,642813549,513874426,1027748829,705979059];function bech32Polymod(e){const t=e>>25;let r=(33554431&e)<<5;for(let n=0;n<POLYMOD_GENERATORS.length;n++)1==(t>>n&1)&&(r^=POLYMOD_GENERATORS[n]);return r}function bechChecksum(e,t,r=1){const n=e.length;let i=1;for(let o=0;o<n;o++){const t=e.charCodeAt(o);if(t<33||t>126)throw new Error(`Invalid prefix (${e})`);i=bech32Polymod(i)^t>>5}i=bech32Polymod(i);for(let o=0;o<n;o++)i=bech32Polymod(i)^31&e.charCodeAt(o);for(let o of t)i=bech32Polymod(i)^o;for(let o=0;o<6;o++)i=bech32Polymod(i);return i^=r,BECH_ALPHABET.encode(convertRadix2([i%powers[30]],30,5,!1))}function genBech32(e){const t="bech32"===e?1:734539939,r=radix2(5),n=r.decode,i=r.encode,o=unsafeWrapper(n);function s(e,r,n=90){astr("bech32.encode prefix",e),isBytes$1(r)&&(r=Array.from(r)),anumArr("bech32.encode",r);const i=e.length;if(0===i)throw new TyLine truncated
|
||||
/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */function isBytes(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function abool(e){if("boolean"!=typeof e)throw new Error(`boolean expected, not ${e}`)}function anumber(e){if(!Number.isSafeInteger(e)||e<0)throw new Error("positive integer expected, got "+e)}function abytes(e,t,r=""){const n=isBytes(e),i=e?.length,o=void 0!==t;if(!n||o&&i!==t)throw new Error((r&&`"${r}" `)+"expected Uint8Array"+(o?` of length ${t}`:"")+", got "+(n?`length=${i}`:"type="+typeof e));return e}function u32(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function clean(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}const isLE=(()=>68===new Uint8Array(new Uint32Array([287454020]).buffer)[0])();function overlapBytes(e,t){return e.buffer===t.buffer&&e.byteOffset<t.byteOffset+t.byteLength&&t.byteOffset<e.byteOffset+e.byteLength}function complexOverlapBytes(e,t){if(overlapBytes(e,t)&&e.byteOffset<t.byteOffset)throw new Error("complex overlap of input and output is not supported")}function checkOpts(e,t){if(null==t||"object"!=typeof t)throw new Error("options must be defined");return Object.assign(e,t)}function equalBytes(e,t){if(e.length!==t.length)return!1;let r=0;for(let n=0;n<e.length;n++)r|=e[n]^t[n];return 0===r}const wrapCipher=(e,t)=>{function r(r,...n){if(abytes(r,void 0,"key"),!isLE)throw new Error("Non little-endian hardware is not yet supported");void 0!==e.nonceLength&&abytes(n[0],e.varSizeNonce?void 0:e.nonceLength,"nonce");const i=e.tagLength;i&&void 0!==n[1]&&abytes(n[1],void 0,"AAD");const o=t(r,...n),s=(e,t)=>{if(void 0!==t){if(2!==e)throw new Error("cipher output not supported");abytes(t,void 0,"output")}};let a=!1;const l={encrypt(e,t){if(a)throw new Error("cannot encrypt() twice with same key + nonce");return a=!0,abytes(e),s(o.encrypt.length,t),o.encrypt(e,t)},decrypt(e,t){if(abytes(e),i&&e.length<i)throw new Error('"ciphertext" expected length bigger than tagLength='+i);return s(o.decrypt.length,t),o.decrypt(e,t)}};return l}return Object.assign(r,e),r};function getOutput(e,t,r=!0){if(void 0===t)return new Uint8Array(e);if(t.length!==e)throw new Error('"output" expected Uint8Array of length '+e+", got: "+t.length);if(r&&!isAligned32$1(t))throw new Error("invalid output, must be aligned");return t}function isAligned32$1(e){return e.byteOffset%4==0}function copyBytes(e){return Uint8Array.from(e)}const BLOCK_SIZE=16,POLY=283;function validateKeyLength(e){if(![16,24,32].includes(e.length))throw new Error('"aes key" expected Uint8Array of length 16/24/32, got length='+e.length)}function mul2(e){return e<<1^POLY&-(e>>7)}function mul(e,t){let r=0;for(;t>0;t>>=1)r^=e&-(1&t),e=mul2(e);return r}const sbox=(()=>{const e=new Uint8Array(256);for(let r=0,n=1;r<256;r++,n^=mul2(n))e[r]=n;const t=new Uint8Array(256);t[0]=99;for(let r=0;r<255;r++){let n=e[255-r];n|=n<<8,t[e[r]]=255&(n^n>>4^n>>5^n>>6^n>>7^99)}return clean(e),t})(),invSbox=sbox.map((e,t)=>sbox.indexOf(t)),rotr32_8=e=>e<<24|e>>>8,rotl32_8=e=>e<<8|e>>>24;function genTtable(e,t){if(256!==e.length)throw new Error("Wrong sbox length");const r=new Uint32Array(256).map((r,n)=>t(e[n])),n=r.map(rotl32_8),i=n.map(rotl32_8),o=i.map(rotl32_8),s=new Uint32Array(65536),a=new Uint32Array(65536),l=new Uint16Array(65536);for(let c=0;c<256;c++)for(let t=0;t<256;t++){const u=256*c+t;s[u]=r[c]^n[t],a[u]=i[c]^o[t],l[u]=e[c]<<8|e[t]}return{sbox:e,sbox2:l,T0:r,T1:n,T2:i,T3:o,T01:s,T23:a}}const tableEncoding=genTtable(sbox,e=>mul(e,3)<<24|e<<16|e<<8|mul(e,2)),tableDecoding=genTtable(invSbox,e=>mul(e,11)<<24|mul(e,13)<<16|mul(e,9)<<8|mul(e,14)),xPowers=(()=>{const e=new Uint8Array(16);for(let t=0,r=1;t<16;t++,r=mul2(r))e[t]=r;return e})();function expandKeyLE(e){abytes(e);const t=e.length;validateKeyLength(e);const{sbox2:r}=tableEncoding,n=[];isAligned32$1(e)||n.push(e=copyBytes(e));const i=u32(e),o=i.length,s=e=>applySbox(r,e,e,e,e),a=new Uint32Array(t+28);a.set(i);for(let l=o;l<a.length;l++){let e=a[l-1];l%o===0?e=s(rotr32_8(e))^xPowers[l/o-1]:o>6&&l%o===4&&(e=s(e)),a[l]=a[l-o]^e}return clean(...n),a}function expandKeyDecLE(e){const t=expandKeyLE(e),r=t.slice(),n=t.length,{sbox2:i}=tableEncoding,{T0:o,T1:s,T2:a,T3:l}=tableDecoding;for(let c=0;c<n;c+=4)for(let e=0;e<4;e++)r[c+e]=t[n-c-4+e];clean(t);for(let c=4;c<n-4;c++){const e=r[c],t=applySbox(i,e,e,e,e);r[c]=o[255&t]^s[t>>>8&255]^a[t>>>16&255]^l[t>>>24]}return r}function apply0123(e,t,r,n,i,o){return e[r<<8&65280|n>>>8&255]^t[i>>>8&65280|o>>>24&255]}function applySbox(e,t,r,n,i){return e[255&t|65280&r]|e[n>>>16&255|i>>>16&65280]<<16}function encrypt$1(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableEncoding;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123(s,a,t,r,n,i),c=e[l++]^apply0123(s,a,r,n,i,t),u=e[l++]^apply0123(s,a,n,i,t,r),h=e[l++]^apply0123(s,a,i,t,r,n);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox(o,t,r,n,i),s1:e[l++]^applySbox(o,rLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
:root{--default-list-color: #fc3;--hover-bg: #f8f9fa;--sidebar-width: 350px;--link-color: #2a7fff;--link-color-visited: #6a4fbf;--marker-color-primary: #ea4335;--marker-color-dark: #b31412;--danger-color: var(--marker-color-primary);--danger-color-dark: var(--marker-color-dark)}html,body{height:100%;overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}button{-webkit-tap-highlight-color:transparent}body{margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400;color:#333}#root,#ember-testing{height:100%}.map-container{background:#f8f9fa;-webkit-tap-highlight-color:transparent;outline:none;position:absolute;inset:0}#rs-widget-container{position:absolute;top:60px;right:10px;z-index:4000;display:none}#rs-widget-container.visible{display:block}.rs-backdrop{position:fixed;inset:0;z-index:3999}.app-header{position:absolute;top:0;left:0;right:0;height:60px;padding:.5rem 1rem;z-index:3000;pointer-events:none;display:grid;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem}@media(max-width:768px){.app-header{padding:.5rem .5rem 0;height:auto;grid-template-columns:1fr auto;grid-template-areas:"search user" "chips chips";row-gap:8px}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{display:flex;align-items:center;grid-area:search;justify-self:start;width:100%}@media not (max-width:768px){.header-left{min-width:300px;max-width:400px}}@media not (max-width:768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;display:flex;justify-content:center;min-width:0}@media not (max-width:768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media(max-width:768px){.header-center{width:100%;overflow:hidden;justify-content:start}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{background:none;border:none;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}.user-avatar-placeholder{width:40px;height:40px;background:#2a3743;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}.user-avatar{width:40px;height:40px;border-radius:50%;object-fit:cover;flex-shrink:0;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{border:2px dashed #ccc;border-radius:8px;text-align:center;transition:all .2s ease;margin:1.5rem 0 1rem;background-color:#ffffff05;cursor:pointer;aspect-ratio:4 / 3}.dropzone.is-dragging{border-color:#61afef;background-color:#61afef0d}.dropzone-label{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;cursor:pointer;color:#898989;width:100%;height:100%}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-grid{display:flex;flex-direction:column;gap:12px;margin-bottom:20px}.photo-upload-item{position:relative;aspect-ratio:4 / 3;border-radius:6px;overflow:hidden;background:#1e262e;width:100%}.photo-upload-item img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;display:block;z-index:1}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{position:absolute;inset:0;background:#0009;display:flex;flex-direction:column;align-items:center;justify-content:center}.upload-status-text{color:#fff;margin-top:1rem;font-size:.9rem;text-shadow:0 1px 3px rgb(0 0 0 / 80%);text-align:center;padding:0 1rem}.photo-upload-item .error-overlay{background:#e06c75cc;cursor:pointer;border:none;padding:0;margin:0;width:100%}.photo-upload-item .btn-remove-photo{position:absolute;top:4px;right:4px;background:#000000b3;border:none;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#fff;padding:0}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{position:absolute;top:100%;right:0;margin-top:10px;width:280px;background:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;padding:1rem;z-index:3001}.menu-backdrop{position:fixed;inset:0;z-index:3000}.account-list{list-style:none;padding:0;margin:0}.account-item{display:flex;flex-direction:column;padding:1rem 0;border-bottom:1px solid #eee}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{display:flex;justify-content:space-between;align-items:center}.account-status{font-size:.85rem;color:#898989;margin-top:.35rem;margin-left:calc(18px + .75rem);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-iLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(o,a,l)=>(l=null!=o?t(i(o)):{},((t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||void 0===l||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t})(!a&&o&&o.__esModule&&s.call(o,"default")?l:e(l,"default",{value:o,enumerable:!0}),o)),c=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var u,h=Object.prototype,d=Symbol("undefined"),p=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(p||{}),f=1,g=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=m(this.source);this._parent=t=null===e||e===h?null:w(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===d?void 0:e}removeDescriptors(t){this.writeDescriptors(t,d)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==d&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?p.ONCE:p.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,p.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=_(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===p.REMOVE&&t.kind!==p.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==f||this.source!==this.proto&&-1!==this._inheritedEnd||f++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<f){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);fLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{align-items:center;gap:.75rem;display:flex}.btn-text{cursor:pointLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-D3dQ-CGf.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
import{c as e,g as a,a as s,s as t,A as r,C as o,b as n,D as i,L as l,N as b,d as c,P as p,e as d,S as m,T as u,_ as g,f as y,h as _,i as O,j as S,k as f,l as C,m as P,n as h,o as v,p as j,q as E,r as M,t as D,u as N,v as V,w as A,x as w,y as T,z as I,B as x,E as R,F as k,G as F,H as L,I as z,J as G,K as U,M as B,O as W,Q as Y,R as H,U as J,V as K,W as X,X as q,Y as Q,Z,$,a0 as ee,a1 as ae,a2 as se,a3 as te,a4 as re,a5 as oe,a6 as ne,a7 as ie,a8 as le,a9 as be,aa as pe,ab as de,ac as me,ad as ue,ae as ge,af as ye,ag as _e,ah as Oe,ai as Se,aj as fe,ak as Ce,al as Pe,am as he,an as ve,ao as je,ap as Ee,aq as Me,ar as De,as as Ne,at as Ve,au as Ae,av as we,aw as Te,ax as Ie,ay as xe,az as Re,aA as ke,aB as Fe,aC as Le,aD as ze,aE as Ge,aF as Ue,aG as Be,aH as We,aI as Ye,aJ as He,aK as Je,aL as Ke,aM as Xe,aN as qe,aO as Qe,aP as Ze,aQ as $e,aR as ea,aS as aa,aT as sa,aU as ta,aV as ra,aW as oa,aX as na,aY as ia,aZ as la,a_ as ba,a$ as pa,b0 as da,b1 as ma,b2 as ua,b3 as ga,b4 as ya,b5 as _a,b6 as Oa,b7 as Sa,b8 as fa,b9 as Ca,ba as Pa,bb as ha,bc as va,bd as ja,be as Ea,bf as Ma,bg as Da,bh as Na,bi as Va,bj as Aa,bk as wa,bl as Ta,bm as Ia,bn as xa,bo as Ra,bp as ka,bq as Fa,br as La,bs as za,bt as Ga,bu as Ua,bv as Ba,bw as Wa,bx as Ya,by as Ha,bz as Ja,bA as Ka,bB as Xa,bC as qa,bD as Qa,bE as Za,bF as $a,bG as es,bH as as,bI as ss,bJ as ts,bK as rs,bL as os,bM as ns,bN as is,bO as ls,bP as bs,bQ as cs,bR as ps,bS as ds,bT as ms,bU as us}from"./main-CLZV93ov.js";export{bV as Application,bW as ApplicationNamespace,bX as Array,bY as ArrayProxy,bZ as Controller,b_ as Debug,b$ as EmberDestroyable,c0 as EmberObject,c1 as EnumerableMutable,cc as GlimmerComponent,cd as GlimmerManager,ce as GlimmerReference,cf as GlimmerRuntime,cg as GlimmerUtil,ch as GlimmerValidator,c2 as Instrumentation,c0 as Object,c3 as ObjectCore,c4 as ObjectEvented,c5 as ObjectObservable,c6 as ObjectPromiseProxyMixin,c7 as ObjectProxy,ca as Owner,c8 as Runloop,c9 as Service,cb as VERSION}from"./main-CLZV93ov.js";const gs=new WeakMap,ys=Object.freeze(Object.defineProperty({__proto__:null,ASYNC_OBSERVERS:r,ComputedDescriptor:o,ComputedProperty:n,DEBUG_INJECTION_FUNCTIONS:i,Libraries:l,NAMESPACES:b,NAMESPACES_BY_ID:c,PROPERTY_DID_CHANGE:p,PROXY_CONTENT:d,SYNC_OBSERVERS:m,TrackedDescriptor:u,_getPath:g,_getProp:y,_setProp:_,activateObserver:O,addArrayObserver:S,addListener:f,addNamespace:C,addObserver:P,alias:h,arrayContentDidChange:v,arrayContentWillChange:j,autoComputed:E,beginPropertyChanges:M,cached:(...s)=>{const[t,r,o]=s,n=new WeakMap,i=o.get;o.get=function(){return n.has(this)||n.set(this,e(i.bind(this))),a(n.get(this))}},changeProperties:D,computed:N,createCache:e,defineDecorator:V,defineProperty:A,defineValue:w,deprecateProperty:function(e,a,r){Object.defineProperty(e,a,{configurable:!0,enumerable:!1,set(e){t(this,r,e)},get(){return s(this,r)}})},descriptorForDecorator:T,descriptorForProperty:I,eachProxyArrayDidChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayDidChange(e,a,s,t)},eachProxyArrayWillChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayWillChange(e,a,s,t)},endPropertyChanges:x,expandProperties:R,findNamespace:k,findNamespaces:F,flushAsyncObservers:L,get:s,getCachedValueFor:z,getProperties:G,getValue:a,hasListeners:U,hasUnknownProperty:B,inject:W,isClassicDecorator:Y,isComputed:H,isConst:J,isElementDescriptor:K,isNamespaceSearchDisabled:X,libraries:q,makeComputedDecorator:Q,markObjectAsDirty:Z,nativeDescDecorator:$,notifyPropertyChange:ee,objectAt:ae,on:se,processAllNamespaces:te,processNamespace:re,removeArrayObserver:oe,removeListener:ne,removeNamespace:ie,removeObserver:le,replace:be,replaceInNativeArray:pe,revalidateObservers:de,sendEvent:me,set:t,setClassicDecorator:ue,setNamespaceSearchDisabled:ge,setProperties:ye,setUnprocessedMixins:_e,tagForObject:Oe,tagForProperty:Se,tracked:fe,trySet:Ce},Symbol.toStringTag,{value:"Module"})),_s=Object.freeze(Object.defineProperty({__proto__:null,Input:Pe,Textarea:he,capabilities:ve,default:je,getComponentTemplate:Ee,setComponentManager:Me,setComponentTemplate:De},Symbol.toStringTag,{value:"Module"})),Os=Object.freeze(Object.defineProperty({__proto__:null,EventTarget:Ne,Promise:Ve,all:Ae,allSettled:we,asap:Te,async:Ie,cast:xe,configure:Re,default:ke,defer:Fe,denodeify:Le,filter:ze,hash:Ge,hashSettled:Ue,map:Be,off:We,on:Ye,race:He,reject:Je,resolve:Ke,rethrow:Xe},Symbol.toStringTag,{value:"Module"})),Ss=Object.freeze(Object.defineProperty({__proto__:null,cacheFor:z,guidFor:qe},Symbol.toStringTag,{value:"Module"})),fs=Object.freeze(Object.defineProperty({__proto__:null,default:Qe},Symbol.toStringTag,{value:"Module"})),Cs=Object.freeze(Object.defineProperty({__proto__:null,ENV:Ze,context:$e,getENV:ea,getLookup:aa,global:sa,setLookup:ta},Symbol.toStringTag,{value:"Module"})),Ps=Object.freeze(Object.defineProperty({__proto__:null,Meta:ra,UNDEFINED:oa,counters:na,meta:ia,peekMeta:la,setMeta:ba},Symbol.toStringTag,{value:"Module"})),hs=Object.freeze(Object.definePropLine truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
import{ci as t,cj as e,ck as i,cl as r,cm as n,cn as s,co as o,cp as a}from"./main-CLZV93ov.js";var h=Array.isArray;function u(){for(var r,n=[],s=0;s<arguments.length;s++)n[s]=arguments[s];return 1===(n=1===(r=n).length&&h(r[0])?r[0]:r).length?t(n[0]):new e(function(e){return function(r){for(var n=[],s=function(s){n.push(t(e[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},o=0;n&&!r.closed&&o<e.length;o++)s(o)}}
|
||||
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */(n))}function l(t,...e){if(!((i=t)instanceof Uint8Array||ArrayBuffer.isView(i)&&"Uint8Array"===i.constructor.name))throw new Error("Uint8Array expected");var i;if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function c(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function d(...t){for(let e=0;e<t.length;e++)t[e].fill(0)}function f(t){return new DataView(t.buffer,t.byteOffset,t.byteLength)}function g(t,e){return t<<32-e|t>>>e}function p(t){return"string"==typeof t&&(t=function(t){if("string"!=typeof t)throw new Error("string expected");return new Uint8Array((new TextEncoder).encode(t))}(t)),l(t),t}class m{}function w(t){const e=e=>t().update(p(e)).digest(),i=t();return e.outputLen=i.outputLen,e.blockLen=i.blockLen,e.create=()=>t(),e}function b(t,e,i){return t&e^~t&i}function y(t,e,i){return t&e^t&i^e&i}class E extends m{constructor(t,e,i,r){super(),this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.blockLen=t,this.outputLen=e,this.padOffset=i,this.isLE=r,this.buffer=new Uint8Array(t),this.view=f(this.buffer)}update(t){c(this),l(t=p(t));const{view:e,buffer:i,blockLen:r}=this,n=t.length;for(let s=0;s<n;){const o=Math.min(r-this.pos,n-s);if(o===r){const e=f(t);for(;r<=n-s;s+=r)this.process(e,s);continue}i.set(t.subarray(s,s+o),this.pos),this.pos+=o,s+=o,this.pos===r&&(this.process(e,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){c(this),function(t,e){l(t);const i=e.outputLen;if(t.length<i)throw new Error("digestInto() expects output buffer of length at least "+i)}(t,this),this.finished=!0;const{buffer:e,view:i,blockLen:r,isLE:n}=this;let{pos:s}=this;e[s++]=128,d(this.buffer.subarray(s)),this.padOffset>r-s&&(this.process(i,0),s=0);for(let l=s;l<r;l++)e[l]=0;!function(t,e,i,r){if("function"==typeof t.setBigUint64)return t.setBigUint64(e,i,r);const n=BigInt(32),s=BigInt(4294967295),o=Number(i>>n&s),a=Number(i&s),h=r?4:0,u=r?0:4;t.setUint32(e+h,o,r),t.setUint32(e+u,a,r)}(i,r-8,BigInt(8*this.length),n),this.process(i,0);const o=f(t),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const h=a/4,u=this.get();if(h>u.length)throw new Error("_sha2: outputLen bigger than state");for(let l=0;l<h;l++)o.setUint32(4*l,u[l],n)}digest(){const{buffer:t,outputLen:e}=this;this.digestInto(t);const i=t.slice(0,e);return this.destroy(),i}_cloneInto(t){t||(t=new this.constructor),t.set(...this.get());const{blockLen:e,buffer:i,length:r,finished:n,destroyed:s,pos:o}=this;return t.destroyed=s,t.finished=n,t.length=r,t.pos=o,r%e&&t.buffer.set(i),t}clone(){return this._cloneInto()}}const _=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),x=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),A=new Uint32Array(64);class L extends E{constructor(t=32){super(64,t,8,!1),this.A=0|_[0],this.B=0|_[1],this.C=0|_[2],this.D=0|_[3],this.E=0|_[4],this.F=0|_[5],this.G=0|_[6],this.H=0|_[7]}get(){const{A:t,B:e,C:i,D:r,E:n,F:s,G:o,H:a}=this;return[t,e,i,r,n,s,o,a]}set(t,e,i,r,n,s,o,a){this.A=0|t,this.B=0|e,this.C=0|i,this.D=0|r,this.E=0|n,this.F=0|s,this.G=0|o,this.H=0|a}process(t,e){for(let l=0;l<16;l++,e+=4)A[l]=t.getUint32(e,!1);for(let l=16;l<64;l++){const t=A[l-15],e=A[l-2],i=g(t,7)^g(t,18)^t>>>3,r=g(e,17)^g(e,19)^e>>>10;A[l]=r+A[l-7]+i+A[l-16]|0}let{A:i,B:r,C:n,D:s,E:o,F:a,G:h,H:u}=this;for(let l=0;l<64;l++){const t=u+(g(o,6)^g(o,11)^g(o,25))+b(o,a,h)+x[l]+A[l]|0,e=(g(i,2)^g(i,13)^g(i,22))+y(i,r,n)|0;u=h,h=a,a=o,o=s+t|0,s=n,n=r,r=i,i=t+e|0}i=i+this.A|0,r=r+this.B|0,n=n+this.C|0,s=s+this.D|0,o=o+this.E|0,a=a+this.F|0,h=h+this.G|0,u=u+this.H|0,this.set(i,r,n,s,o,a,h,u)}roundClean(){d(A)}destroy(){this.set(0,0,0,0,0,0,0,0),d(this.buffer)}}const U=w(()=>new L),I=32;class B{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(t){if(t instanceof B&&(t=t.unwrap()),"number"!=typeof t.length)throw Error("bad length");const e=t.length+this.length;if(this.capacity<e){const t=this._raw,i=Math.max(2*this.capacity,e)Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-D3dQ-CGf.js";function l(){for(var t=[],r=0;r<arguments.length;r++)t[r]=arguments[r];return 1===(t=n(t)).length?s(t[0]):new e(function(t){return function(e){for(var r=[],n=function(n){r.push(s(t[n]).subscribe(i(e,function(t){if(r){for(var i=0;i<r.length;i++)i!==n&&r[i].unsubscribe();r=null}e.next(t)})))},a=0;r&&!e.closed&&a<t.length;a++)n(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
+2
-2
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-D3dQ-CGf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-P9lvOtv_.css">
|
||||
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async fetchOsmObject() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
{
|
||||
id: 'place-123',
|
||||
title: 'Mountain Trail',
|
||||
geohash: 'u33dc0',
|
||||
osmTags: { name: 'Mountain Trail' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
|
||||
},
|
||||
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
if (id === 'place-123') {
|
||||
return this.savedPlaces[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve([this.savedPlaces[0]]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
module('Acceptance | collections navigation', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
});
|
||||
|
||||
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
|
||||
// 1. Visit Home Map
|
||||
await visit('/');
|
||||
assert.strictEqual(currentURL(), '/');
|
||||
|
||||
// 2. Open the App Menu overlay
|
||||
await click('.menu-btn-integrated');
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
assert
|
||||
.dom('.app-menu')
|
||||
.includesText('Collections', 'Menu contains Collections link');
|
||||
|
||||
// 3. Transition to Collections Index (List of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText('Collections', 'Header is centered and titled Collections');
|
||||
assert
|
||||
.dom('.lists-index-item')
|
||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
||||
|
||||
// 4. Transition to a specific list (Want to go)
|
||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Transitions instantly to /lists/to-go'
|
||||
);
|
||||
|
||||
// 5. Verify background loading spinner shows up, then results populate
|
||||
await waitFor('.places-list');
|
||||
assert
|
||||
.dom('.places-list .place-name')
|
||||
.hasText('Mountain Trail', 'Renders the saved place from the list');
|
||||
assert
|
||||
.dom('.places-list .place-type')
|
||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||
|
||||
// 6. Click back button in collection list header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||
|
||||
// 7. Click back button in collections index header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||
|
||||
// 8. Close sidebar
|
||||
await click('.sidebar-header .close-btn');
|
||||
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
||||
});
|
||||
|
||||
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Click on the place item to view details
|
||||
await click('.place-item');
|
||||
assert.ok(
|
||||
currentURL().includes('/place/place-123'),
|
||||
'Transitions to place details route'
|
||||
);
|
||||
|
||||
// Click back from place details
|
||||
await click('.back-btn');
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Returns gracefully back to lists/to-go list view'
|
||||
);
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
'Should have stayed on the search route with markers intact'
|
||||
);
|
||||
|
||||
// Second Click (Clear search and markers)
|
||||
// Second Click (Start new search)
|
||||
// Click slightly differently to ensure fresh event
|
||||
await triggerEvent(canvas, 'pointerdown', {
|
||||
clientX: 250,
|
||||
@@ -125,6 +125,11 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
// 3. Wait for transition
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Should have transitioned to index');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ module('Acceptance | navigation', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar transitions back to search route when opened from search results', async function (assert) {
|
||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
@@ -97,22 +97,7 @@ module('Acceptance | navigation', function (hooks) {
|
||||
await click('.close-btn');
|
||||
|
||||
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');
|
||||
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
||||
});
|
||||
|
||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||
|
||||
@@ -169,77 +169,4 @@ module('Acceptance | search loading', function (hooks) {
|
||||
// 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,76 +270,4 @@ 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,93 +4,6 @@ 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
|
||||
@@ -99,8 +12,6 @@ function setupMapStyleMocks(hooks) {
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
setupMapStyleMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
@@ -121,7 +32,6 @@ function setupApplicationTest(hooks, options) {
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
@@ -129,7 +39,6 @@ function setupRenderingTest(hooks, options) {
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
setupLocalForageMock(hooks);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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,19 +49,11 @@ export class MockNostrDataService extends Service {
|
||||
return [];
|
||||
}
|
||||
|
||||
get requiredReadRelays() {
|
||||
get defaultReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get requiredWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get mailboxReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get mailboxWriteRelays() {
|
||||
get defaultWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
@service settings;
|
||||
|
||||
requiredReadRelays = ['wss://nostr.kosmos.org'];
|
||||
requiredWriteRelays = [];
|
||||
|
||||
mailboxReadRelays = ['wss://mailbox.example.com'];
|
||||
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
|
||||
|
||||
get configuredReadRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxReadRelays,
|
||||
...(this.settings.nostrReadRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrReadRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get configuredWriteRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxWriteRelays,
|
||||
...(this.settings.nostrWriteRelays || []),
|
||||
]);
|
||||
|
||||
return excludeRequiredRelays(
|
||||
configured,
|
||||
this.settings.nostrWriteRelayExclusions || []
|
||||
);
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredReadRelays,
|
||||
this.configuredReadRelays
|
||||
);
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
return mergeRequiredRelays(
|
||||
this.requiredWriteRelays,
|
||||
this.configuredWriteRelays
|
||||
);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
function readRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[0];
|
||||
return [...list.querySelectorAll('li')];
|
||||
}
|
||||
|
||||
function writeRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[1];
|
||||
return [...list.querySelectorAll('li')];
|
||||
}
|
||||
|
||||
function rowByText(rows, text) {
|
||||
return rows.find((row) => row.textContent.includes(text));
|
||||
}
|
||||
|
||||
module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
localStorage.removeItem('marco:settings');
|
||||
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.settings = this.owner.lookup('service:settings');
|
||||
this.onChange = () => {};
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
localStorage.removeItem('marco:settings');
|
||||
});
|
||||
|
||||
async function renderAndOpenDetails(context) {
|
||||
await render(
|
||||
<template><AppMenuSettingsNostr @onChange={{this.onChange}} /></template>
|
||||
);
|
||||
await click('summary');
|
||||
return context.element;
|
||||
}
|
||||
|
||||
test('required read relay is first and non-removable', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const rows = readRows(element);
|
||||
|
||||
assert.dom(rows[0]).includesText('nostr.kosmos.org');
|
||||
|
||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||
|
||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||
});
|
||||
|
||||
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
|
||||
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.deepEqual(this.settings.nostrReadRelayExclusions, [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
});
|
||||
|
||||
test('removing custom read relay updates custom list without exclusions', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
|
||||
await click(customRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
});
|
||||
|
||||
test('adding read relay clears existing exclusion for same relay', async function (assert) {
|
||||
this.settings.update('nostrReadRelayExclusions', [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
await fillIn('#new-read-relay', 'Mailbox.EXAMPLE.com/');
|
||||
await click(element.querySelector('#new-read-relay').nextElementSibling);
|
||||
|
||||
assert.deepEqual(this.settings.nostrReadRelays, [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
});
|
||||
|
||||
test('reset read relays clears additions and exclusions', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
this.settings.update('nostrReadRelayExclusions', [
|
||||
'wss://mailbox.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
await click(element.querySelectorAll('.reset-relays')[0]);
|
||||
|
||||
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||
|
||||
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||
assert.dom(requiredRow).exists();
|
||||
});
|
||||
|
||||
test('write relays are removable and mailbox delete stores exclusion', async function (assert) {
|
||||
this.settings.update('nostrWriteRelays', [
|
||||
'wss://custom-write.example.com',
|
||||
]);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const rows = writeRows(element);
|
||||
|
||||
assert.true(
|
||||
rows.every((row) => row.querySelector('.btn-remove-relay')),
|
||||
'all write relays can be removed'
|
||||
);
|
||||
|
||||
const mailboxRow = rowByText(rows, 'mailbox-write.example.com');
|
||||
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||
|
||||
assert.deepEqual(this.settings.nostrWriteRelayExclusions, [
|
||||
'wss://mailbox-write.example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, settled } from '@ember/test-helpers';
|
||||
import ContributionPhoto from 'marco/components/contribution-photo';
|
||||
import { ContributionEntry } from 'marco/utils/contributions';
|
||||
|
||||
function noop() {}
|
||||
|
||||
module('Integration | Component | contribution-photo', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.noop = noop;
|
||||
});
|
||||
|
||||
test('it renders the place name and thumbnail', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Café Example',
|
||||
placeNameLoading: false,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 60 * 60,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/photo.jpg',
|
||||
thumbUrl: 'https://x.com/thumb.jpg',
|
||||
alt: 'A nice café',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-place').hasText('Café Example');
|
||||
assert
|
||||
.dom('.contribution-thumb img')
|
||||
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
||||
assert.dom('.contribution-tag').hasText('food');
|
||||
assert.dom('.contribution-thumb-badge').doesNotExist();
|
||||
});
|
||||
|
||||
test('it shows a +N badge when there are multiple photos', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Park',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
url: 'https://x.com/2.jpg',
|
||||
thumbUrl: 'https://x.com/t2.jpg',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
url: 'https://x.com/3.jpg',
|
||||
thumbUrl: 'https://x.com/t3.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-thumb-badge').hasText('+2');
|
||||
});
|
||||
|
||||
test('it shows a loading label when placeNameLoading is true', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: null,
|
||||
placeNameLoading: true,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-name-loading').hasText('Loading…');
|
||||
});
|
||||
|
||||
test('clicking the item fires @onSelect with the item', async function (assert) {
|
||||
this.item = {
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
placeName: 'Test',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let selected = null;
|
||||
this.handleSelect = (item) => {
|
||||
selected = item;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto
|
||||
@item={{this.item}}
|
||||
@onSelect={{this.handleSelect}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.contribution-item');
|
||||
|
||||
assert.strictEqual(selected, this.item);
|
||||
});
|
||||
|
||||
test('it updates the place name when a tracked entry resolves after render', async function (assert) {
|
||||
// Regression: when a place name is resolved by the background batch fetch
|
||||
// after the entry has already been rendered as "Loading…", the component
|
||||
// must re-render with the resolved name. This requires the entry's
|
||||
// placeName/placeNameLoading to be tracked.
|
||||
this.item = new ContributionEntry({
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
osmType: 'node',
|
||||
osmId: '12345',
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-name-loading').hasText('Loading…');
|
||||
|
||||
// Simulate the contributions service resolving the name after the batch fetch
|
||||
this.item.placeName = 'Resolved Café';
|
||||
this.item.placeNameLoading = false;
|
||||
await settled();
|
||||
|
||||
assert.dom('.contribution-place').hasText('Resolved Café');
|
||||
assert.dom('.contribution-name-loading').doesNotExist();
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import ContributionsTimeline from 'marco/components/contributions-timeline';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
|
||||
function noop() {}
|
||||
|
||||
class MockToastService extends Service {
|
||||
show() {}
|
||||
}
|
||||
|
||||
module('Integration | Component | contributions-timeline', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:toast', MockToastService);
|
||||
|
||||
this.noop = noop;
|
||||
this.emptyItems = [];
|
||||
});
|
||||
|
||||
test('it renders a loading state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{true}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.sidebar-loading').exists();
|
||||
assert.dom('.contributions-list').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders a not-connected state', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('Connect your Nostr account');
|
||||
});
|
||||
|
||||
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="modal-portal"></div>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{false}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.nostr-connect-modal').doesNotExist();
|
||||
|
||||
await click('.empty-state a');
|
||||
|
||||
assert.dom('.nostr-connect-modal').exists();
|
||||
});
|
||||
|
||||
test('it renders an empty state when connected but no contributions', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('No contributions yet');
|
||||
});
|
||||
|
||||
test('it renders contribution items', async function (assert) {
|
||||
this.items = [
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:111',
|
||||
osmType: 'node',
|
||||
osmId: '111',
|
||||
placeName: 'Café One',
|
||||
placeNameLoading: false,
|
||||
createdAt: 2000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/1.jpg',
|
||||
thumbUrl: 'https://x.com/t1.jpg',
|
||||
tags: ['food'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'photo',
|
||||
placeIdentifier: 'osm:node:222',
|
||||
osmType: 'node',
|
||||
osmId: '222',
|
||||
placeName: 'Park Two',
|
||||
placeNameLoading: false,
|
||||
createdAt: 1000,
|
||||
photos: [
|
||||
{
|
||||
url: 'https://x.com/2.jpg',
|
||||
thumbUrl: 'https://x.com/t2.jpg',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.items}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.noop}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.contribution-item').exists({ count: 2 });
|
||||
assert.dom(this.element).includesText('Café One');
|
||||
assert.dom(this.element).includesText('Park Two');
|
||||
});
|
||||
|
||||
test('clicking the back button fires @onBack', async function (assert) {
|
||||
let backClicked = false;
|
||||
this.handleBack = () => {
|
||||
backClicked = true;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<ContributionsTimeline
|
||||
@items={{this.emptyItems}}
|
||||
@isLoading={{false}}
|
||||
@isConnected={{true}}
|
||||
@onBack={{this.handleBack}}
|
||||
@onClose={{this.noop}}
|
||||
@onSelect={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.true(backClicked);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,6 @@ import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const USER_A = 'a'.repeat(64);
|
||||
const USER_B = 'b'.repeat(64);
|
||||
|
||||
class MockBlossomService extends Service {
|
||||
async delete() {
|
||||
return true;
|
||||
@@ -37,7 +34,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
pubkey: 'userA',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||
thumbUrl:
|
||||
@@ -45,7 +42,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
},
|
||||
{
|
||||
eventId: 'event2',
|
||||
pubkey: USER_B,
|
||||
pubkey: 'userB',
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo2.jpg',
|
||||
},
|
||||
@@ -58,7 +55,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it does not show delete button if user is not creator', async function (assert) {
|
||||
this.nostrAuth.pubkey = USER_B; // Different from photo1's pubkey
|
||||
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
@@ -83,7 +80,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||
this.nostrAuth.pubkey = USER_A; // Matches photo1's pubkey
|
||||
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
@@ -109,7 +106,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it handles cancellation of deletion', async function (assert) {
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
@@ -136,7 +133,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||
// Override the mock's getter just for this test
|
||||
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||
@@ -144,11 +141,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
get: () => ({
|
||||
signEvent: async (e) => ({
|
||||
...e,
|
||||
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||
pubkey: USER_A,
|
||||
id: 'signed-id',
|
||||
sig: 'sig',
|
||||
pubkey: 'userA',
|
||||
}),
|
||||
getPublicKey: async () => USER_A,
|
||||
getPublicKey: async () => 'userA',
|
||||
}),
|
||||
});
|
||||
this.selectedPhoto = this.photos[0];
|
||||
@@ -230,7 +227,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.nostrAuth.pubkey = 'userA';
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
const clipboardStub = sinon
|
||||
|
||||
@@ -320,95 +320,18 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||
|
||||
// Verify it stripped the dashes, spaces and leading plus for the wa.me URL
|
||||
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/44987654321'
|
||||
'https://wa.me/+44987654321'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/12345678900'
|
||||
'https://wa.me/+12345678900'
|
||||
);
|
||||
|
||||
// Verify it kept the dashes and spaces for the visible text
|
||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||
});
|
||||
|
||||
test('it strips parentheses, dots and the leading plus from whatsapp hrefs', async function (assert) {
|
||||
const place = {
|
||||
title: 'Chat Shop',
|
||||
osmTags: {
|
||||
whatsapp: '+504-9850-3802;(504) 9850.3802;+1.234.567.8900',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const links = this.element.querySelectorAll('a[href^="https://wa.me/"]');
|
||||
assert.strictEqual(links.length, 3, 'Rendered exactly 3 WhatsApp links');
|
||||
|
||||
assert.strictEqual(
|
||||
links[0].getAttribute('href'),
|
||||
'https://wa.me/50498503802'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[1].getAttribute('href'),
|
||||
'https://wa.me/50498503802'
|
||||
);
|
||||
assert.strictEqual(
|
||||
links[2].getAttribute('href'),
|
||||
'https://wa.me/12345678900'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
|
||||
const place = {
|
||||
title: 'OSM Place',
|
||||
osmId: '12345',
|
||||
osmType: 'node',
|
||||
lat: 52.520008,
|
||||
lon: 13.404954,
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const osmLink = this.element.querySelector(
|
||||
'.meta-info a[href^="https://www.openstreetmap.org/node/12345"]'
|
||||
);
|
||||
assert.ok(osmLink, 'OpenStreetMap link is rendered');
|
||||
assert.strictEqual(
|
||||
osmLink.getAttribute('href'),
|
||||
'https://www.openstreetmap.org/node/12345'
|
||||
);
|
||||
|
||||
assert.dom('button.btn-link').hasText('Add a photo');
|
||||
});
|
||||
|
||||
test('it renders correct search-based OpenStreetMap link for a custom saved place', async function (assert) {
|
||||
class MockMapUi extends Service {
|
||||
currentCenter = { lat: 52.5, lon: 13.4 };
|
||||
currentZoom = 15.6;
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUi);
|
||||
|
||||
const place = {
|
||||
title: 'Custom Place',
|
||||
lat: 52.520008,
|
||||
lon: 13.404954,
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const osmLink = this.element.querySelector(
|
||||
'.meta-info a[href^="https://www.openstreetmap.org/search"]'
|
||||
);
|
||||
assert.ok(osmLink, 'OpenStreetMap search link is rendered');
|
||||
assert.strictEqual(
|
||||
osmLink.getAttribute('href'),
|
||||
'https://www.openstreetmap.org/search?lat=52.520008&lon=13.404954&zoom=16#map=16/52.50000/13.40000'
|
||||
);
|
||||
|
||||
assert.dom('button.btn-link').doesNotExist();
|
||||
});
|
||||
});
|
||||
@@ -97,27 +97,4 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
||||
|
||||
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders an optional alt text input after upload selection', async function (assert) {
|
||||
this.place = {
|
||||
title: 'Cafe Alpha',
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
osmTags: { amenity: 'cafe' },
|
||||
};
|
||||
|
||||
await render(
|
||||
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||
);
|
||||
|
||||
assert.dom('#photo-alt-input').doesNotExist();
|
||||
|
||||
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
await selectFile(this.element, file);
|
||||
|
||||
assert.dom('#photo-alt-input').exists();
|
||||
assert
|
||||
.dom('#photo-alt-input')
|
||||
.hasAttribute('placeholder', 'Describe this photo');
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
function makePhotoEvent({
|
||||
id,
|
||||
created_at,
|
||||
placeIdentifier = 'osm:node:123',
|
||||
url = 'https://example.com/photo.jpg',
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
pubkey: 'pubkey-1',
|
||||
kind: 360,
|
||||
created_at,
|
||||
tags: [
|
||||
['i', placeIdentifier],
|
||||
['imeta', `url ${url}`, 'dim 800x600'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
store = {
|
||||
timeline() {
|
||||
return {
|
||||
subscribe() {
|
||||
return { unsubscribe() {} };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async loadMyContributions() {}
|
||||
}
|
||||
|
||||
class MockOsmService extends Service {
|
||||
async getCachedOsmObject() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async fetchOsmObjectsBatch() {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
module('Unit | Service | contributions', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from bookmarks immediately', function (assert) {
|
||||
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
|
||||
service.storage.findPlaceById = (id) =>
|
||||
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
|
||||
|
||||
service._updateItems(events);
|
||||
|
||||
assert.strictEqual(service.items.length, 1);
|
||||
assert.false(service.items[0].placeNameLoading, 'Not loading');
|
||||
assert.strictEqual(
|
||||
service.items[0].placeName,
|
||||
'Bookmarked Café',
|
||||
'Resolved from bookmark'
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from the persistent name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:999',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
await service.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
'osm:node:999',
|
||||
'Cached Park'
|
||||
);
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Cached Park');
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
});
|
||||
|
||||
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:42',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
const map = new Map();
|
||||
map.set('node:42', { title: 'Test Place' });
|
||||
return map;
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
|
||||
'Test Place',
|
||||
'Name is persisted to the IndexedDB name cache'
|
||||
);
|
||||
});
|
||||
|
||||
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:77',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
await service.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
'osm:node:77',
|
||||
'Persisted Place'
|
||||
);
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
});
|
||||
|
||||
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:555',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
// OSM batch returns empty (simulating all-failed fetch)
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events);
|
||||
|
||||
// Wait for the background batch to complete
|
||||
await flushPromises();
|
||||
|
||||
assert.false(
|
||||
service.items[0].placeNameLoading,
|
||||
'Item is no longer loading after batch failure'
|
||||
);
|
||||
assert.strictEqual(
|
||||
service.items[0].placeName,
|
||||
'OSM node 555',
|
||||
'Fallback name is applied'
|
||||
);
|
||||
});
|
||||
|
||||
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:666',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
// Fallback should be shown but NOT cached
|
||||
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
|
||||
assert.notOk(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
|
||||
'No fallback name persisted in the IndexedDB name cache'
|
||||
);
|
||||
assert.true(
|
||||
service._unresolvable.has('osm:node:666'),
|
||||
'Item is marked unresolvable for this session'
|
||||
);
|
||||
});
|
||||
|
||||
test('unresolvable items are not re-fetched within the same session', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:777',
|
||||
}),
|
||||
];
|
||||
|
||||
let fetchCount = 0;
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
fetchCount++;
|
||||
return new Map();
|
||||
};
|
||||
|
||||
// First update triggers a fetch
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
|
||||
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
|
||||
|
||||
// Second update should NOT trigger another fetch (already unresolvable)
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
|
||||
assert.strictEqual(
|
||||
service.items[0].placeName,
|
||||
'OSM node 777',
|
||||
'Fallback is shown immediately'
|
||||
);
|
||||
});
|
||||
|
||||
test('successful batch resolution stores names in the name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:111',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
const map = new Map();
|
||||
map.set('node:111', { title: 'Resolved Café' });
|
||||
return map;
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
||||
assert.strictEqual(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
|
||||
'Resolved Café',
|
||||
'Name is stored in the name cache'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -292,205 +292,4 @@ module('Unit | Service | osm', function (hooks) {
|
||||
'Call with different lat/lon should trigger fetch'
|
||||
);
|
||||
});
|
||||
|
||||
test('_storeInMemoryAndLocalStorage rejects entries without a title', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service._storeInMemoryAndLocalStorage('node:1', 'node', '1', {
|
||||
title: null,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
});
|
||||
|
||||
assert.false(
|
||||
service.cachedPlaces.has('node:1'),
|
||||
'Does not cache entries without a title'
|
||||
);
|
||||
|
||||
service._storeInMemoryAndLocalStorage('node:2', 'node', '2', null);
|
||||
assert.false(
|
||||
service.cachedPlaces.has('node:2'),
|
||||
'Does not cache null entries'
|
||||
);
|
||||
});
|
||||
|
||||
test('_storeInMemoryAndLocalStorage accepts entries with a title', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service._storeInMemoryAndLocalStorage('node:3', 'node', '3', {
|
||||
title: 'Café Example',
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
});
|
||||
|
||||
assert.true(
|
||||
service.cachedPlaces.has('node:3'),
|
||||
'Caches entries with a title'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObjectsBatch does not write to the general OSM cache', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service.fetchWithRetry = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{ id: 100, type: 'node', lat: 1, lon: 2, tags: { name: 'Node 100' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
|
||||
|
||||
assert.false(
|
||||
service.cachedPlaces.has('node:100'),
|
||||
'Batch fetch does not write to the in-memory OSM cache'
|
||||
);
|
||||
assert.notOk(
|
||||
await service.localForage.get('osm-cache', 'node:100'),
|
||||
'Batch fetch does not write to the persistent OSM cache'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObjectsBatch returns resolved places in the result Map', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service.fetchWithRetry = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{ id: 200, type: 'node', lat: 1, lon: 2, tags: { name: 'Café 200' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.fetchOsmObjectsBatch([
|
||||
{ osmType: 'node', osmId: '200' },
|
||||
]);
|
||||
|
||||
assert.true(result.has('node:200'), 'Result contains the fetched place');
|
||||
assert.strictEqual(
|
||||
result.get('node:200').title,
|
||||
'Café 200',
|
||||
'Result has the correct title'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObjectsBatch uses .json suffix in the API URL', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
let capturedUrls = [];
|
||||
service.fetchWithRetry = async (url) => {
|
||||
capturedUrls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{ id: 300, type: 'node', lat: 1, lon: 2, tags: { name: 'Test' } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '300' }]);
|
||||
|
||||
assert.true(
|
||||
capturedUrls.some((url) => url.includes('.json')),
|
||||
'Batch fetch URL includes .json suffix'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObjectsBatch uses correct pluralized endpoints', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
let capturedUrls = [];
|
||||
service.fetchWithRetry = async (url) => {
|
||||
capturedUrls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ elements: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
await service.fetchOsmObjectsBatch([
|
||||
{ osmType: 'node', osmId: '1' },
|
||||
{ osmType: 'way', osmId: '2' },
|
||||
{ osmType: 'relation', osmId: '3' },
|
||||
]);
|
||||
|
||||
assert.true(
|
||||
capturedUrls.some((u) => u.includes('/nodes.json?nodes=')),
|
||||
'Uses nodes.json endpoint'
|
||||
);
|
||||
assert.true(
|
||||
capturedUrls.some((u) => u.includes('/ways.json?ways=')),
|
||||
'Uses ways.json endpoint'
|
||||
);
|
||||
assert.true(
|
||||
capturedUrls.some((u) => u.includes('/relations.json?relations=')),
|
||||
'Uses relations.json endpoint'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObject with forceFresh bypasses the cache and returns fresh data', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
// Seed both caches so we can prove forceFresh skips them.
|
||||
service.cachedPlaces.set('node:5', {
|
||||
data: { title: 'Stale In-Memory', lat: 1, lon: 1 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await service.localForage.set('osm-cache', 'node:5', {
|
||||
data: { title: 'Stale IndexedDB', lat: 1, lon: 1 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
service.fetchWithRetry = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{
|
||||
id: 5,
|
||||
type: 'node',
|
||||
lat: 2,
|
||||
lon: 3,
|
||||
tags: { name: 'Fresh From API' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.fetchOsmObject('5', 'node', {
|
||||
forceFresh: true,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.title,
|
||||
'Fresh From API',
|
||||
'Returns fresh API data, not cached data'
|
||||
);
|
||||
assert.strictEqual(result.lat, 2);
|
||||
assert.strictEqual(result.lon, 3);
|
||||
});
|
||||
|
||||
test('fetchOsmObject without forceFresh returns the in-memory cache entry without hitting the API', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service.cachedPlaces.set('node:6', {
|
||||
data: { title: 'Warm In-Memory', lat: 4, lon: 5 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
let fetchCalled = 0;
|
||||
service.fetchWithRetry = async () => {
|
||||
fetchCalled++;
|
||||
return { ok: true, json: async () => ({ elements: [] }) };
|
||||
};
|
||||
|
||||
const result = await service.fetchOsmObject('6', 'node');
|
||||
|
||||
assert.strictEqual(fetchCalled, 0, 'API was not hit');
|
||||
assert.strictEqual(result.title, 'Warm In-Memory');
|
||||
});
|
||||
});
|
||||
@@ -15,10 +15,8 @@ module('Unit | Service | storage', function (hooks) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
// Stub OSM Service
|
||||
let capturedOptions;
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type, options) {
|
||||
capturedOptions = options;
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
@@ -51,10 +49,6 @@ module('Unit | Service | storage', function (hooks) {
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
||||
assert.ok(
|
||||
capturedOptions?.forceFresh,
|
||||
'refreshPlace fetches fresh OSM data (forceFresh: true)'
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { groupPhotoContributions } from 'marco/utils/contributions';
|
||||
|
||||
function makePhotoEvent({
|
||||
id,
|
||||
created_at,
|
||||
placeIdentifier = 'osm:node:123',
|
||||
url = 'https://example.com/photo.jpg',
|
||||
tags = [],
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
pubkey: 'pubkey-1',
|
||||
kind: 360,
|
||||
created_at,
|
||||
tags: [
|
||||
['i', placeIdentifier],
|
||||
['imeta', `url ${url}`, 'dim 800x600'],
|
||||
...tags.map((t) => ['t', t]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeletionEvent({ id, targetId }) {
|
||||
return {
|
||||
id,
|
||||
pubkey: 'pubkey-1',
|
||||
kind: 5,
|
||||
created_at: 999,
|
||||
tags: [['e', targetId]],
|
||||
};
|
||||
}
|
||||
|
||||
module('Unit | Utility | contributions', function () {
|
||||
test('groupPhotoContributions returns empty for no events', function (assert) {
|
||||
assert.deepEqual(groupPhotoContributions([]), []);
|
||||
assert.deepEqual(groupPhotoContributions(null), []);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions creates one entry per event for the same place when spread out', function (assert) {
|
||||
// Two events 10 hours apart for the same place -> two entries
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
url: 'https://x.com/1.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 1000 + 10 * 60 * 60,
|
||||
url: 'https://x.com/2.jpg',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 2);
|
||||
// Newest first
|
||||
assert.strictEqual(entries[0].photos[0].url, 'https://x.com/2.jpg');
|
||||
assert.strictEqual(entries[1].photos[0].url, 'https://x.com/1.jpg');
|
||||
});
|
||||
|
||||
test('groupPhotoContributions groups events for the same place within the threshold', function (assert) {
|
||||
// Two events 30 minutes apart -> one entry with two photos
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
url: 'https://x.com/1.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 1000 + 30 * 60,
|
||||
url: 'https://x.com/2.jpg',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].photos.length, 2);
|
||||
assert.strictEqual(entries[0].eventCount, 2);
|
||||
// createdAt = newest event's created_at
|
||||
assert.strictEqual(entries[0].createdAt, 1000 + 30 * 60);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions orders photos within a group oldest-first', function (assert) {
|
||||
// Three events within the threshold for the same place
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e3',
|
||||
created_at: 3000,
|
||||
url: 'https://x.com/3.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
url: 'https://x.com/1.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 2000,
|
||||
url: 'https://x.com/2.jpg',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(
|
||||
entries[0].photos[0].url,
|
||||
'https://x.com/1.jpg',
|
||||
'First photo is the oldest'
|
||||
);
|
||||
assert.strictEqual(
|
||||
entries[0].photos[1].url,
|
||||
'https://x.com/2.jpg',
|
||||
'Second photo is the middle'
|
||||
);
|
||||
assert.strictEqual(
|
||||
entries[0].photos[2].url,
|
||||
'https://x.com/3.jpg',
|
||||
'Third photo is the newest'
|
||||
);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions groups by OSM entity identifier', function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:111',
|
||||
url: 'https://x.com/a.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:222',
|
||||
url: 'https://x.com/b.jpg',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 2);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions sorts entries newest-first', function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'old',
|
||||
created_at: 100,
|
||||
placeIdentifier: 'osm:node:111',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'new',
|
||||
created_at: 200,
|
||||
placeIdentifier: 'osm:node:222',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'mid',
|
||||
created_at: 150,
|
||||
placeIdentifier: 'osm:node:333',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:222');
|
||||
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:333');
|
||||
assert.strictEqual(entries[2].placeIdentifier, 'osm:node:111');
|
||||
});
|
||||
|
||||
test('groupPhotoContributions respects custom threshold hours', function (assert) {
|
||||
// Events 2 hours apart. With threshold 1, they are separate; with 3, they group.
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
url: 'https://x.com/1.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 1000 + 2 * 60 * 60,
|
||||
url: 'https://x.com/2.jpg',
|
||||
}),
|
||||
];
|
||||
|
||||
assert.strictEqual(groupPhotoContributions(events, 1).length, 2);
|
||||
assert.strictEqual(groupPhotoContributions(events, 3).length, 1);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions parses place identifier into osmType and osmId', function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:way:98765',
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries[0].osmType, 'way');
|
||||
assert.strictEqual(entries[0].osmId, '98765');
|
||||
});
|
||||
|
||||
test('groupPhotoContributions attaches t tags to photos', function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
tags: ['food', 'vibe'],
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.deepEqual(entries[0].photos[0].tags, ['food', 'vibe']);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions excludes events deleted by kind 5', function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
url: 'https://x.com/1.jpg',
|
||||
}),
|
||||
makePhotoEvent({
|
||||
id: 'e2',
|
||||
created_at: 2000,
|
||||
url: 'https://x.com/2.jpg',
|
||||
}),
|
||||
makeDeletionEvent({ id: 'd1', targetId: 'e1' }),
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].photos[0].url, 'https://x.com/2.jpg');
|
||||
});
|
||||
|
||||
test('groupPhotoContributions skips events without an i tag', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'no-i',
|
||||
pubkey: 'pubkey-1',
|
||||
kind: 360,
|
||||
created_at: 1000,
|
||||
tags: [['imeta', 'url https://x.com/1.jpg', 'dim 800x600']],
|
||||
},
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 0);
|
||||
});
|
||||
|
||||
test('groupPhotoContributions skips events without a usable imeta', function (assert) {
|
||||
const events = [makePhotoEvent({ id: 'e1', created_at: 1000, url: null })];
|
||||
|
||||
// The helper always sets a URL, so override manually
|
||||
events[0].tags = [
|
||||
['i', 'osm:node:123'],
|
||||
['imeta', 'dim 800x600'],
|
||||
];
|
||||
|
||||
const entries = groupPhotoContributions(events);
|
||||
assert.strictEqual(entries.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { module, test } from 'qunit';
|
||||
import {
|
||||
formatRelativeDate,
|
||||
humanizeOsmTag,
|
||||
capitalize,
|
||||
} from 'marco/utils/format-text';
|
||||
|
||||
module('Unit | Utility | format-text', function () {
|
||||
test('humanizeOsmTag replaces underscores and dashes with spaces and title-cases', function (assert) {
|
||||
assert.strictEqual(humanizeOsmTag('atm'), 'Atm');
|
||||
assert.strictEqual(humanizeOsmTag('fast_food'), 'Fast Food');
|
||||
assert.strictEqual(humanizeOsmTag('phone-repair'), 'Phone Repair');
|
||||
assert.strictEqual(humanizeOsmTag(''), '');
|
||||
assert.strictEqual(humanizeOsmTag(null), '');
|
||||
});
|
||||
|
||||
test('capitalize uppercases the first letter', function (assert) {
|
||||
assert.strictEqual(capitalize('hello'), 'Hello');
|
||||
assert.strictEqual(capitalize(''), '');
|
||||
assert.strictEqual(capitalize(null), '');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns empty string for falsy timestamp', function (assert) {
|
||||
assert.strictEqual(formatRelativeDate(null), '');
|
||||
assert.strictEqual(formatRelativeDate(0), '');
|
||||
assert.strictEqual(formatRelativeDate(undefined), '');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns "just now" for timestamps within the last minute', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
assert.strictEqual(formatRelativeDate(now), 'just now');
|
||||
assert.strictEqual(formatRelativeDate(now - 10), 'just now');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns "min ago" for timestamps within the last hour', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
assert.strictEqual(formatRelativeDate(now - 60), '1 min ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 300), '5 min ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 3599), '59 min ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns "hr ago" for timestamps within the last day', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
assert.strictEqual(formatRelativeDate(now - 3600), '1 hr ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 7200), '2 hr ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 86399), '23 hr ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns "day(s) ago" for timestamps within the last week', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
assert.strictEqual(formatRelativeDate(now - 86400), '1 day ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 86400 * 2), '2 days ago');
|
||||
assert.strictEqual(formatRelativeDate(now - 86400 * 6), '6 days ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oldTimestamp = now - 86400 * 30; // 30 days ago
|
||||
const result = formatRelativeDate(oldTimestamp);
|
||||
// The exact format depends on the locale, but it should contain a year
|
||||
assert.ok(
|
||||
result.includes(String(new Date().getFullYear())),
|
||||
'Contains the year'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,5 @@
|
||||
import { module, test } from 'qunit';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
parsePlacePhotos,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
|
||||
|
||||
module('Unit | Utility | nostr', function () {
|
||||
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
|
||||
@@ -147,121 +141,4 @@ module('Unit | Utility | nostr', function () {
|
||||
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
|
||||
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||
});
|
||||
|
||||
test('parsePlacePhotos extracts alt text from imeta', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 100,
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/photo.jpg',
|
||||
'dim 800x600',
|
||||
'alt A sunny terrace overlooking the bay',
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].alt, 'A sunny terrace overlooking the bay');
|
||||
});
|
||||
|
||||
test('parsePlacePhotos strips the legacy placeholder alt text', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 100,
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/photo.jpg',
|
||||
'dim 800x600',
|
||||
'alt A photo of a place',
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].alt, null);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos leaves alt null when not provided', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 100,
|
||||
tags: [['imeta', 'url https://example.com/photo.jpg', 'dim 800x600']],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].alt, null);
|
||||
});
|
||||
|
||||
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||
const relays = uniqNormalizedRelays([
|
||||
'Relay.example.com',
|
||||
'wss://relay.example.com/',
|
||||
'wss://other.example.com',
|
||||
]);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://relay.example.com',
|
||||
'wss://other.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mergeRequiredRelays keeps required relays as-is and merges normalized custom relays', function (assert) {
|
||||
const relays = mergeRequiredRelays(
|
||||
['wss://required.example.com', 'required-2.example.com'],
|
||||
['required-2.example.com/', 'wss://custom.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://required.example.com',
|
||||
'required-2.example.com',
|
||||
'wss://required-2.example.com',
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('excludeRequiredRelays removes required relays from normalized custom list', function (assert) {
|
||||
const relays = excludeRequiredRelays(
|
||||
[
|
||||
'wss://required.example.com',
|
||||
'custom.example.com',
|
||||
'ws://custom2.example.com',
|
||||
],
|
||||
['wss://required.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://custom.example.com',
|
||||
'ws://custom2.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('excludeRequiredRelays trusts required list without normalizing it', function (assert) {
|
||||
const relays = excludeRequiredRelays(
|
||||
['Required.example.com', 'custom.example.com'],
|
||||
['required.example.com']
|
||||
);
|
||||
|
||||
assert.deepEqual(relays, [
|
||||
'wss://required.example.com',
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons';
|
||||
import { getIcon } from 'marco/utils/icons';
|
||||
import { getIconNameForTags } from 'marco/utils/osm-icons';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
module('Unit | Utility | osm-icons', function () {
|
||||
@@ -37,14 +36,4 @@ module('Unit | Utility | osm-icons', function () {
|
||||
let result = getIconNameForTags({ foo: 'bar' });
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
||||
for (let rule of POI_ICON_RULES) {
|
||||
let icon = getIcon(rule.icon);
|
||||
assert.ok(
|
||||
icon,
|
||||
`Icon "${rule.icon}" specified in POI_ICON_RULES should be imported and available in getIcon`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -89,12 +89,6 @@ module('Unit | Utility | osm', function (hooks) {
|
||||
assert.strictEqual(result, 'Building');
|
||||
});
|
||||
|
||||
test('getPlaceType ignores generic "yes" values if a more specific tag is present', function (assert) {
|
||||
const tags = { historic: 'yes', building: 'tower' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, 'Tower');
|
||||
});
|
||||
|
||||
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||
// If something is both a shop and a building, it should be a shop
|
||||
const tags = { building: 'yes', shop: 'supermarket' };
|
||||
|
||||
Reference in new issue
Block a user