Compare commits
34 Commits
v1.21.3
...
c11882adfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
c11882adfb
|
|||
|
707f4ac11c
|
|||
|
3bada05b63
|
|||
|
f01730fef5
|
|||
|
448c51bab6
|
|||
|
0bcbae374b
|
|||
|
c33fe3b268
|
|||
|
18bda60310
|
|||
|
86d25dc6ba
|
|||
|
5b8bec6a00
|
|||
|
f2c2eb1fdc
|
|||
|
b42c4881f6
|
|||
|
b18e299eca
|
|||
|
401ed41fcd
|
|||
|
504e8fab94
|
|||
|
76897c9e69
|
|||
|
59bc5ca046
|
|||
|
ef4bb8f51a
|
|||
|
f82a797720
|
|||
|
f9cb22ee0e
|
|||
|
a77ea0c97d
|
|||
|
208b77a294
|
|||
|
ea3e4dd0dc
|
|||
|
2c2a3e2a4c
|
|||
|
d266bb92bd
|
|||
|
200100686d
|
|||
|
70d2fe1c6c
|
|||
|
6329ad986d
|
|||
|
bcfa81494e
|
|||
|
bc42694707
|
|||
|
4390b7d699
|
|||
|
7bab8dfa09
|
|||
|
51c9555273
|
|||
|
14827fce3e
|
@@ -6,6 +6,7 @@ import Icon from '#components/icon';
|
|||||||
import AppMenuSettingsMapUi from './settings/map-ui';
|
import AppMenuSettingsMapUi from './settings/map-ui';
|
||||||
import AppMenuSettingsApis from './settings/apis';
|
import AppMenuSettingsApis from './settings/apis';
|
||||||
import AppMenuSettingsNostr from './settings/nostr';
|
import AppMenuSettingsNostr from './settings/nostr';
|
||||||
|
import AppMenuSettingsExperimental from './settings/experimental';
|
||||||
|
|
||||||
export default class AppMenuSettings extends Component {
|
export default class AppMenuSettings extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -35,6 +36,7 @@ export default class AppMenuSettings extends Component {
|
|||||||
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||||
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||||
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||||
|
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
export default class AppMenuSettingsExperimental extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="alert-triangle" @size={{20}} />
|
||||||
|
<span>Experimental</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="experimental-enable-photo-deletion">Enable photo deletion
|
||||||
|
(own photos)</label>
|
||||||
|
<select
|
||||||
|
id="experimental-enable-photo-deletion"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "experimentalEnablePhotoDeletion")}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if
|
||||||
|
this.settings.experimentalEnablePhotoDeletion
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless
|
||||||
|
this.settings.experimentalEnablePhotoDeletion
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -5,7 +5,11 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { normalizeRelayUrl } from '../../../utils/nostr';
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
} from '../../../utils/nostr';
|
||||||
|
|
||||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||||
|
|
||||||
@@ -17,6 +21,74 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
@tracked newReadRelay = '';
|
@tracked newReadRelay = '';
|
||||||
@tracked newWriteRelay = '';
|
@tracked newWriteRelay = '';
|
||||||
|
|
||||||
|
get customReadRelays() {
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
this.settings.nostrReadRelays || [],
|
||||||
|
this.nostrData.requiredReadRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get customWriteRelays() {
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
this.settings.nostrWriteRelays || [],
|
||||||
|
this.nostrData.requiredWriteRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readRelayExclusions() {
|
||||||
|
return this.settings.nostrReadRelayExclusions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get writeRelayExclusions() {
|
||||||
|
return this.settings.nostrWriteRelayExclusions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get requiredReadRelaySet() {
|
||||||
|
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
get requiredWriteRelaySet() {
|
||||||
|
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxReadRelaySet() {
|
||||||
|
return new Set(this.nostrData.mailboxReadRelays);
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxWriteRelaySet() {
|
||||||
|
return new Set(this.nostrData.mailboxWriteRelays);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasReadOverrides() {
|
||||||
|
return (
|
||||||
|
this.customReadRelays.length > 0 || this.readRelayExclusions.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasWriteOverrides() {
|
||||||
|
return (
|
||||||
|
this.customWriteRelays.length > 0 || this.writeRelayExclusions.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readRelaysForDisplay() {
|
||||||
|
return this.nostrData.activeReadRelays.map((url) => {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isRequired: this.requiredReadRelaySet.has(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get writeRelaysForDisplay() {
|
||||||
|
return this.nostrData.activeWriteRelays.map((url) => {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isRequired: this.requiredWriteRelaySet.has(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateNewReadRelay(event) {
|
updateNewReadRelay(event) {
|
||||||
this.newReadRelay = event.target.value;
|
this.newReadRelay = event.target.value;
|
||||||
@@ -32,19 +104,51 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
const url = normalizeRelayUrl(this.newReadRelay);
|
const url = normalizeRelayUrl(this.newReadRelay);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const current =
|
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [
|
||||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
...this.customReadRelays,
|
||||||
const set = new Set([...current, url]);
|
url,
|
||||||
this.settings.update('nostrReadRelays', Array.from(set));
|
]);
|
||||||
|
const custom = excludeRequiredRelays(
|
||||||
|
merged,
|
||||||
|
this.nostrData.requiredReadRelays
|
||||||
|
);
|
||||||
|
|
||||||
|
const readExclusions = this.readRelayExclusions.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== url;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.update('nostrReadRelays', custom.length > 0 ? custom : null);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelayExclusions',
|
||||||
|
readExclusions.length > 0 ? readExclusions : null
|
||||||
|
);
|
||||||
this.newReadRelay = '';
|
this.newReadRelay = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeReadRelay(url) {
|
removeReadRelay(url) {
|
||||||
const current =
|
if (this.requiredReadRelaySet.has(url)) {
|
||||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
return;
|
||||||
const filtered = current.filter((r) => r !== url);
|
}
|
||||||
this.settings.update('nostrReadRelays', filtered);
|
|
||||||
|
const normalizedUrl = normalizeRelayUrl(url);
|
||||||
|
|
||||||
|
const remainingCustom = this.customReadRelays.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextExclusions = this.mailboxReadRelaySet.has(normalizedUrl)
|
||||||
|
? Array.from(new Set([...this.readRelayExclusions, normalizedUrl]))
|
||||||
|
: this.readRelayExclusions;
|
||||||
|
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelays',
|
||||||
|
remainingCustom.length > 0 ? remainingCustom : null
|
||||||
|
);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelayExclusions',
|
||||||
|
nextExclusions.length > 0 ? nextExclusions : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -64,6 +168,7 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
@action
|
@action
|
||||||
resetReadRelays() {
|
resetReadRelays() {
|
||||||
this.settings.update('nostrReadRelays', null);
|
this.settings.update('nostrReadRelays', null);
|
||||||
|
this.settings.update('nostrReadRelayExclusions', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -71,24 +176,57 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const current =
|
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [
|
||||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
...this.customWriteRelays,
|
||||||
const set = new Set([...current, url]);
|
url,
|
||||||
this.settings.update('nostrWriteRelays', Array.from(set));
|
]);
|
||||||
|
const custom = excludeRequiredRelays(
|
||||||
|
merged,
|
||||||
|
this.nostrData.requiredWriteRelays
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeExclusions = this.writeRelayExclusions.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== url;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.update('nostrWriteRelays', custom.length > 0 ? custom : null);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelayExclusions',
|
||||||
|
writeExclusions.length > 0 ? writeExclusions : null
|
||||||
|
);
|
||||||
this.newWriteRelay = '';
|
this.newWriteRelay = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeWriteRelay(url) {
|
removeWriteRelay(url) {
|
||||||
const current =
|
if (this.requiredWriteRelaySet.has(url)) {
|
||||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
return;
|
||||||
const filtered = current.filter((r) => r !== url);
|
}
|
||||||
this.settings.update('nostrWriteRelays', filtered);
|
|
||||||
|
const normalizedUrl = normalizeRelayUrl(url);
|
||||||
|
|
||||||
|
const remainingCustom = this.customWriteRelays.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextExclusions = this.mailboxWriteRelaySet.has(normalizedUrl)
|
||||||
|
? Array.from(new Set([...this.writeRelayExclusions, normalizedUrl]))
|
||||||
|
: this.writeRelayExclusions;
|
||||||
|
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelays',
|
||||||
|
remainingCustom.length > 0 ? remainingCustom : null
|
||||||
|
);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelayExclusions',
|
||||||
|
nextExclusions.length > 0 ? nextExclusions : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
resetWriteRelays() {
|
resetWriteRelays() {
|
||||||
this.settings.update('nostrWriteRelays', null);
|
this.settings.update('nostrWriteRelays', null);
|
||||||
|
this.settings.update('nostrWriteRelayExclusions', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -109,6 +247,100 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="details-content form-layout">
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-read-relay">Read Relays</label>
|
||||||
|
<ul class="relay-list">
|
||||||
|
{{#each this.readRelaysForDisplay as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay.url}}</span>
|
||||||
|
{{#unless relay.isRequired}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{/unless}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="add-relay-input">
|
||||||
|
<input
|
||||||
|
id="new-read-relay"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
value={{this.newReadRelay}}
|
||||||
|
{{on "input" this.updateNewReadRelay}}
|
||||||
|
{{on "keydown" this.handleReadRelayKeydown}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
{{on "click" this.addReadRelay}}
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.hasReadOverrides}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetReadRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-write-relay">Write Relays</label>
|
||||||
|
<ul class="relay-list">
|
||||||
|
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay.url}}</span>
|
||||||
|
{{#unless relay.isRequired}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{/unless}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="add-relay-input">
|
||||||
|
<input
|
||||||
|
id="new-write-relay"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
value={{this.newWriteRelay}}
|
||||||
|
{{on "input" this.updateNewWriteRelay}}
|
||||||
|
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
{{on "click" this.addWriteRelay}}
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.hasWriteOverrides}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetWriteRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||||
servers</label>
|
servers</label>
|
||||||
@@ -135,96 +367,6 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="new-read-relay">Read Relays</label>
|
|
||||||
<ul class="relay-list">
|
|
||||||
{{#each this.nostrData.activeReadRelays as |relay|}}
|
|
||||||
<li>
|
|
||||||
<span>{{stripProtocol relay}}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-remove-relay"
|
|
||||||
title="Remove relay"
|
|
||||||
aria-label="Remove"
|
|
||||||
{{on "click" (fn this.removeReadRelay relay)}}
|
|
||||||
>
|
|
||||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
<div class="add-relay-input">
|
|
||||||
<input
|
|
||||||
id="new-read-relay"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="relay.example.com"
|
|
||||||
value={{this.newReadRelay}}
|
|
||||||
{{on "input" this.updateNewReadRelay}}
|
|
||||||
{{on "keydown" this.handleReadRelayKeydown}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
{{on "click" this.addReadRelay}}
|
|
||||||
>Add</button>
|
|
||||||
</div>
|
|
||||||
{{#if this.settings.nostrReadRelays}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-link reset-relays"
|
|
||||||
{{on "click" this.resetReadRelays}}
|
|
||||||
>
|
|
||||||
Reset to Defaults
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="new-write-relay">Write Relays</label>
|
|
||||||
<ul class="relay-list">
|
|
||||||
{{#each this.nostrData.activeWriteRelays as |relay|}}
|
|
||||||
<li>
|
|
||||||
<span>{{stripProtocol relay}}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-remove-relay"
|
|
||||||
title="Remove relay"
|
|
||||||
aria-label="Remove"
|
|
||||||
{{on "click" (fn this.removeWriteRelay relay)}}
|
|
||||||
>
|
|
||||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
<div class="add-relay-input">
|
|
||||||
<input
|
|
||||||
id="new-write-relay"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="relay.example.com"
|
|
||||||
value={{this.newWriteRelay}}
|
|
||||||
{{on "input" this.updateNewWriteRelay}}
|
|
||||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
{{on "click" this.addWriteRelay}}
|
|
||||||
>Add</button>
|
|
||||||
</div>
|
|
||||||
{{#if this.settings.nostrWriteRelays}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-link reset-relays"
|
|
||||||
{{on "click" this.resetWriteRelays}}
|
|
||||||
>
|
|
||||||
Reset to Defaults
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Cached data</label>
|
<label>Cached data</label>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+47
-21
@@ -1,9 +1,39 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
|
||||||
|
const ModalContent = <template>
|
||||||
|
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
{{on "click" @stopProp}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||||
|
disabled={{@disableClose}}
|
||||||
|
{{on "click" @close}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{yield}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
export default class Modal extends Component {
|
export default class Modal extends Component {
|
||||||
|
get isTesting() {
|
||||||
|
return config.environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationElement() {
|
||||||
|
return document.getElementById('modal-portal') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
stopProp(e) {
|
stopProp(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -18,28 +48,24 @@ export default class Modal extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
{{#if this.isTesting}}
|
||||||
class="modal-overlay"
|
<ModalContent
|
||||||
role="dialog"
|
@close={{this.close}}
|
||||||
tabindex="-1"
|
@stopProp={{this.stopProp}}
|
||||||
{{on "click" this.close}}
|
@disableClose={{@disableClose}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="modal-content"
|
|
||||||
role="document"
|
|
||||||
tabindex="0"
|
|
||||||
{{on "click" this.stopProp}}
|
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
|
||||||
disabled={{@disableClose}}
|
|
||||||
{{on "click" this.close}}
|
|
||||||
>
|
|
||||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
|
||||||
</button>
|
|
||||||
{{yield}}
|
{{yield}}
|
||||||
</div>
|
</ModalContent>
|
||||||
</div>
|
{{else}}
|
||||||
|
{{#in-element this.destinationElement}}
|
||||||
|
<ModalContent
|
||||||
|
@close={{this.close}}
|
||||||
|
@stopProp={{this.stopProp}}
|
||||||
|
@disableClose={{@disableClose}}
|
||||||
|
>
|
||||||
|
{{yield}}
|
||||||
|
</ModalContent>
|
||||||
|
{{/in-element}}
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Icon from './icon';
|
|||||||
import fadeInImage from '../modifiers/fade-in-image';
|
import fadeInImage from '../modifiers/fade-in-image';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
|
||||||
export default class PhotoCarousel extends Component {
|
export default class PhotoCarousel extends Component {
|
||||||
@tracked canScrollLeft = false;
|
@tracked canScrollLeft = false;
|
||||||
@@ -55,6 +56,8 @@ export default class PhotoCarousel extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
isProgrammaticScroll = false;
|
||||||
|
|
||||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||||
if (eventId && eventId !== this.lastEventId) {
|
if (eventId && eventId !== this.lastEventId) {
|
||||||
const isInitial = !this.lastEventId;
|
const isInitial = !this.lastEventId;
|
||||||
@@ -65,6 +68,9 @@ export default class PhotoCarousel extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.internalEventId = eventId;
|
||||||
|
this.isProgrammaticScroll = true;
|
||||||
|
|
||||||
const scrollAction = () => {
|
const scrollAction = () => {
|
||||||
const targetSlide = element.querySelector(
|
const targetSlide = element.querySelector(
|
||||||
`[data-event-id="${eventId}"]`
|
`[data-event-id="${eventId}"]`
|
||||||
@@ -78,11 +84,18 @@ export default class PhotoCarousel extends Component {
|
|||||||
// Restore smooth scroll after the jump
|
// Restore smooth scroll after the jump
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
element.style.scrollBehavior = originalScrollBehavior;
|
element.style.scrollBehavior = originalScrollBehavior;
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
}, 50);
|
}, 50);
|
||||||
} else {
|
} else {
|
||||||
// Use native CSS smooth scrolling for subsequent clicks
|
// Use native CSS smooth scrolling for subsequent clicks
|
||||||
element.scrollLeft = targetSlide.offsetLeft;
|
element.scrollLeft = targetSlide.offsetLeft;
|
||||||
|
// Clear programmatic scroll flag after a delay to let scroll finish
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,10 +124,16 @@ export default class PhotoCarousel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let intersectionObserver;
|
let intersectionObserver;
|
||||||
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
|
if (
|
||||||
|
this.args.onVisiblePhotoChange &&
|
||||||
|
window.IntersectionObserver &&
|
||||||
|
config.environment !== 'test'
|
||||||
|
) {
|
||||||
// Set up intersection observer to track which photo is currently "most" visible
|
// Set up intersection observer to track which photo is currently "most" visible
|
||||||
intersectionObserver = new IntersectionObserver(
|
intersectionObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
if (this.isProgrammaticScroll) return;
|
||||||
|
|
||||||
for (let entry of entries) {
|
for (let entry of entries) {
|
||||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||||
const eventId = entry.target.dataset.eventId;
|
const eventId = entry.target.dataset.eventId;
|
||||||
|
|||||||
@@ -1,17 +1,122 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
import { inject as service } from '@ember/service';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import Icon from '#components/icon';
|
import { service } from '@ember/service';
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { EventFactory } from 'applesauce-factory';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
import DropdownMenu from './dropdown-menu';
|
||||||
import PhotoCarousel from './photo-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
import DropdownMenu from '#components/dropdown-menu';
|
import Icon from './icon';
|
||||||
|
|
||||||
|
const GalleryContent = <template>
|
||||||
|
<div
|
||||||
|
class="photo-gallery-overlay"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
{{on "click" @handleBackgroundClick}}
|
||||||
|
{{@bindKeyboard @handleKeydown}}
|
||||||
|
>
|
||||||
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
|
<div
|
||||||
|
class="photo-gallery-content"
|
||||||
|
data-current-event-id={{@currentPhoto.eventId}}
|
||||||
|
>
|
||||||
|
<div class="actions-btn-container">
|
||||||
|
<DropdownMenu
|
||||||
|
@iconSize={{24}}
|
||||||
|
@triggerIcon="more-horizontal"
|
||||||
|
@iconColor="white"
|
||||||
|
as |closeMenu|
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @copyEventId closeMenu)}}
|
||||||
|
>Copy Photo Event ID</button>
|
||||||
|
{{#if @canDeletePhoto}}
|
||||||
|
<button
|
||||||
|
class="dropdown-item text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||||
|
>Delete Photo</button>
|
||||||
|
{{/if}}
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-btn btn-text"
|
||||||
|
{{on "click" @handleClose}}
|
||||||
|
aria-label="Close gallery"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} @color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="main-photo-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-main"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{@currentPhoto.eventId}}
|
||||||
|
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thumbnail-strip-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-thumbnails"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{@currentPhoto.eventId}}
|
||||||
|
@onPhotoClick={{@selectPhoto}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
export default class PhotoGallery extends Component {
|
export default class PhotoGallery extends Component {
|
||||||
|
get isTesting() {
|
||||||
|
return config.environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationElement() {
|
||||||
|
return document.getElementById('modal-portal') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
@service toast;
|
@service toast;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service nostrRelay;
|
||||||
|
@service blossom;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||||
|
|
||||||
|
get isCreator() {
|
||||||
|
return (
|
||||||
|
this.currentPhoto?.pubkey &&
|
||||||
|
this.nostrAuth.pubkey &&
|
||||||
|
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canDeletePhoto() {
|
||||||
|
return (
|
||||||
|
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard = modifier((element, [handler]) => {
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleClose() {
|
handleClose() {
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
@@ -46,6 +151,28 @@ export default class PhotoGallery extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.args.photos || this.args.photos.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex - 1];
|
||||||
|
} else if (
|
||||||
|
e.key === 'ArrowRight' &&
|
||||||
|
currentIndex < this.args.photos.length - 1
|
||||||
|
) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async copyEventId(closeMenu) {
|
async copyEventId(closeMenu) {
|
||||||
if (this.currentPhoto?.eventId) {
|
if (this.currentPhoto?.eventId) {
|
||||||
@@ -60,65 +187,109 @@ export default class PhotoGallery extends Component {
|
|||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async (closeMenu) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventId = this.currentPhoto.eventId;
|
||||||
|
|
||||||
|
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const tags = [['e', eventId]];
|
||||||
|
|
||||||
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
// Now that the event is published, try to delete from Blossom
|
||||||
|
const hashRegex = /[0-9a-f]{64}/i;
|
||||||
|
|
||||||
|
if (this.currentPhoto.url) {
|
||||||
|
const match = this.currentPhoto.url.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete main image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPhoto.thumbUrl) {
|
||||||
|
const match = this.currentPhoto.thumbUrl.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete thumb image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toast.show('Photo deleted successfully');
|
||||||
|
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
this.handleClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete photo:', e);
|
||||||
|
this.toast.show('Failed to delete photo: ' + e.message);
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
{{#if this.isTesting}}
|
||||||
class="photo-gallery-overlay"
|
<GalleryContent
|
||||||
role="dialog"
|
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||||
tabindex="-1"
|
@bindKeyboard={{this.bindKeyboard}}
|
||||||
{{on "click" this.handleBackgroundClick}}
|
@handleKeydown={{this.handleKeydown}}
|
||||||
>
|
@copyEventId={{this.copyEventId}}
|
||||||
{{! template-lint-disable no-invalid-interactive }}
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
<div class="photo-gallery-content">
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
<div class="actions-btn-container">
|
@handleClose={{this.handleClose}}
|
||||||
<DropdownMenu
|
@photos={{@photos}}
|
||||||
@iconSize={{24}}
|
@currentPhoto={{this.currentPhoto}}
|
||||||
@triggerIcon="more-horizontal"
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
@iconColor="white"
|
@placeName={{@placeName}}
|
||||||
as |closeMenu|
|
@selectPhoto={{this.selectPhoto}}
|
||||||
>
|
/>
|
||||||
<button
|
{{else}}
|
||||||
class="dropdown-item"
|
{{#in-element this.destinationElement}}
|
||||||
type="button"
|
<GalleryContent
|
||||||
{{on "click" (fn this.copyEventId closeMenu)}}
|
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||||
>Copy Photo Event ID</button>
|
@bindKeyboard={{this.bindKeyboard}}
|
||||||
<button
|
@handleKeydown={{this.handleKeydown}}
|
||||||
class="dropdown-item"
|
@copyEventId={{this.copyEventId}}
|
||||||
type="button"
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
{{on "click" closeMenu}}
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
>Report Photo</button>
|
@handleClose={{this.handleClose}}
|
||||||
</DropdownMenu>
|
@photos={{@photos}}
|
||||||
</div>
|
@currentPhoto={{this.currentPhoto}}
|
||||||
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
<button
|
@placeName={{@placeName}}
|
||||||
type="button"
|
@selectPhoto={{this.selectPhoto}}
|
||||||
class="close-btn btn-text"
|
/>
|
||||||
{{on "click" this.handleClose}}
|
{{/in-element}}
|
||||||
aria-label="Close gallery"
|
{{/if}}
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
<Icon @name="x" @size={{24}} @color="white" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="main-photo-container">
|
|
||||||
<PhotoCarousel
|
|
||||||
@variant="gallery-main"
|
|
||||||
@photos={{@photos}}
|
|
||||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
|
||||||
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
|
||||||
@name={{@placeName}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="thumbnail-strip-container">
|
|
||||||
<PhotoCarousel
|
|
||||||
@variant="gallery-thumbnails"
|
|
||||||
@photos={{@photos}}
|
|
||||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
|
||||||
@onPhotoClick={{this.selectPhoto}}
|
|
||||||
@name={{@placeName}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default class PlaceDetails extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service mapUi;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
@tracked isPhotoUploadActive = false;
|
@tracked isPhotoUploadActive = false;
|
||||||
@@ -345,9 +346,21 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
get osmUrl() {
|
get osmUrl() {
|
||||||
const id = this.place.osmId;
|
const id = this.place.osmId;
|
||||||
if (!id) return null;
|
if (id) {
|
||||||
const type = this.place.osmType || 'node';
|
const type = this.place.osmType || 'node';
|
||||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = this.place.lat;
|
||||||
|
const lon = this.place.lon;
|
||||||
|
if (!lat || !lon) return null;
|
||||||
|
|
||||||
|
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
|
||||||
|
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
|
||||||
|
const zoom = this.mapUi.currentZoom ?? 17;
|
||||||
|
const roundedZoom = Math.round(zoom);
|
||||||
|
|
||||||
|
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get gmapsUrl() {
|
get gmapsUrl() {
|
||||||
@@ -591,7 +604,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.osmUrl}}
|
{{#if this.place.osmId}}
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="feather-camera" />
|
<Icon @name="feather-camera" />
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { task } from 'ember-concurrency';
|
|||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
import PlacePhotoUploadItem from './place-photo-upload-item';
|
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
|
import { getSuggestedPhotoTags } from '../utils/photo-tag-suggestions';
|
||||||
|
import capitalize from '../helpers/capitalize';
|
||||||
|
import includes from '../helpers/includes';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
import { or, not } from 'ember-truth-helpers';
|
import { or, not } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class PlacePhotoUpload extends Component {
|
export default class PlacePhotoUpload extends Component {
|
||||||
@@ -22,6 +26,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
@tracked error = '';
|
@tracked error = '';
|
||||||
@tracked isPublishing = false;
|
@tracked isPublishing = false;
|
||||||
@tracked isDragging = false;
|
@tracked isDragging = false;
|
||||||
|
@tracked selectedTags = [];
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -37,6 +42,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get suggestedTags() {
|
||||||
|
return getSuggestedPhotoTags(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleFileSelect(event) {
|
handleFileSelect(event) {
|
||||||
this.addFile(event.target.files[0]);
|
this.addFile(event.target.files[0]);
|
||||||
@@ -93,11 +102,22 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
this.selectedTags = [];
|
||||||
if (this.args.onUploadStateChange) {
|
if (this.args.onUploadStateChange) {
|
||||||
this.args.onUploadStateChange(false);
|
this.args.onUploadStateChange(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleTag(tag) {
|
||||||
|
if (this.selectedTags.includes(tag)) {
|
||||||
|
this.selectedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTags = [tag];
|
||||||
|
}
|
||||||
|
|
||||||
deletePhotoTask = task(async (photoData) => {
|
deletePhotoTask = task(async (photoData) => {
|
||||||
try {
|
try {
|
||||||
if (photoData.hash) {
|
if (photoData.hash) {
|
||||||
@@ -139,6 +159,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
|
|
||||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
for (const tag of this.selectedTags) {
|
||||||
|
tags.push(['t', tag]);
|
||||||
|
}
|
||||||
|
|
||||||
if (lat && lon) {
|
if (lat && lon) {
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
@@ -227,6 +251,26 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.suggestedTags.length}}
|
||||||
|
<div class="photo-tag-suggestions">
|
||||||
|
<p class="photo-tag-suggestions-title">
|
||||||
|
Choose a tag/category (optional):
|
||||||
|
</p>
|
||||||
|
<div class="photo-tag-suggestions-list">
|
||||||
|
{{#each this.suggestedTags as |tag|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="photo-tag-chip
|
||||||
|
{{if (includes this.selectedTags tag) 'is-selected'}}"
|
||||||
|
{{on "click" (fn this.toggleTag tag)}}
|
||||||
|
>
|
||||||
|
{{capitalize tag}}
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-publish"
|
class="btn btn-primary btn-publish"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
|
|||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
this.searchTask.perform(value);
|
this.searchTask.perform(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatSavedPlace(place) {
|
||||||
|
const listNames = (place._listIds || [])
|
||||||
|
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const description = listNames
|
||||||
|
? `Saved place (${listNames})`
|
||||||
|
: 'Saved place';
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'saved',
|
||||||
|
id: place.id,
|
||||||
|
title: place.title,
|
||||||
|
icon: 'bookmark',
|
||||||
|
description,
|
||||||
|
osmId: place.osmId,
|
||||||
|
osmType: place.osmType,
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async (term) => {
|
searchTask = task({ restartable: true }, async (term) => {
|
||||||
await timeout(300);
|
await timeout(300);
|
||||||
|
|
||||||
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
icon: 'search',
|
icon: 'search',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Filter saved places (minimum 3 characters)
|
||||||
|
let savedMatches = [];
|
||||||
|
if (q.length >= 3) {
|
||||||
|
savedMatches = this.storage.savedPlaces
|
||||||
|
.filter((p) => p.title && p.title.toLowerCase().includes(q))
|
||||||
|
.map((p) => this.formatSavedPlace(p));
|
||||||
|
}
|
||||||
|
|
||||||
const results = await this.photon.search(query, lat, lon);
|
const results = await this.photon.search(query, lat, lon);
|
||||||
this.results = [...categoryMatches, ...results];
|
|
||||||
|
// Deduplicate Photon results that are already in saved matches
|
||||||
|
const savedOsmIds = new Set(
|
||||||
|
savedMatches.map((s) => s.osmId).filter(Boolean)
|
||||||
|
);
|
||||||
|
const filteredPhotonResults = results.filter(
|
||||||
|
(r) => !savedOsmIds.has(r.osmId)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.results = [
|
||||||
|
...categoryMatches,
|
||||||
|
...savedMatches,
|
||||||
|
...filteredPhotonResults,
|
||||||
|
];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
|
|||||||
}
|
}
|
||||||
this.results = []; // Hide popover
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it's a custom saved place without an OSM ID, go to place details via internal ID
|
||||||
if (place.osmId) {
|
if (place.source === 'saved' && place.id && !place.osmId) {
|
||||||
|
this.router.transitionTo('place', place.id);
|
||||||
|
}
|
||||||
|
// If it has an OSM ID, go to place details via OSM ID
|
||||||
|
else if (place.osmId) {
|
||||||
// Format: osm:node:123
|
// Format: osm:node:123
|
||||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
|||||||
@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
|
|||||||
// Search with Photon (using lat/lon for bias if available)
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
// Search local bookmarks by name
|
// Search local bookmarks by name (minimum 3 characters)
|
||||||
const queryLower = params.q.toLowerCase();
|
const queryLower = params.q.toLowerCase();
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
let localMatches = [];
|
||||||
return (
|
if (queryLower.length >= 3) {
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
return (
|
||||||
);
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
});
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Merge local matches
|
// Merge local matches
|
||||||
localMatches.forEach((local) => {
|
localMatches.forEach((local) => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
import { capitalize as format } from '../utils/format-text';
|
||||||
|
|
||||||
|
export default helper(function capitalize([text]) {
|
||||||
|
return format(text);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
|
||||||
|
export default helper(function includes([collection, value]) {
|
||||||
|
if (!Array.isArray(collection)) return false;
|
||||||
|
return collection.includes(value);
|
||||||
|
});
|
||||||
+56
-31
@@ -6,7 +6,12 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
|||||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
import { NostrIDB, openDB } from 'nostr-idb';
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
import { normalizeRelayUrl } from '../utils/nostr';
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
uniqNormalizedRelays,
|
||||||
|
} from '../utils/nostr';
|
||||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||||
|
|
||||||
const DIRECTORY_RELAYS = [
|
const DIRECTORY_RELAYS = [
|
||||||
@@ -55,10 +60,11 @@ export default class NostrDataService extends Service {
|
|||||||
this._stopPersisting = persistEventsToCache(
|
this._stopPersisting = persistEventsToCache(
|
||||||
this.store,
|
this.store,
|
||||||
async (events) => {
|
async (events) => {
|
||||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||||
const toCache = events.filter(
|
const toCache = events.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.kind === 0 ||
|
e.kind === 0 ||
|
||||||
|
e.kind === 5 ||
|
||||||
e.kind === 10002 ||
|
e.kind === 10002 ||
|
||||||
e.kind === 10063 ||
|
e.kind === 10063 ||
|
||||||
e.kind === 360
|
e.kind === 360
|
||||||
@@ -82,43 +88,62 @@ export default class NostrDataService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultReadRelays() {
|
get requiredReadRelays() {
|
||||||
const mailboxes = (this.mailboxes?.inboxes || [])
|
return DEFAULT_READ_RELAYS;
|
||||||
.map(normalizeRelayUrl)
|
|
||||||
.filter(Boolean);
|
|
||||||
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
|
||||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultWriteRelays() {
|
get requiredWriteRelays() {
|
||||||
const mailboxes = (this.mailboxes?.outboxes || [])
|
return DEFAULT_WRITE_RELAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxReadRelays() {
|
||||||
|
return (this.mailboxes?.inboxes || [])
|
||||||
.map(normalizeRelayUrl)
|
.map(normalizeRelayUrl)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const defaults =
|
}
|
||||||
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
|
||||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
get mailboxWriteRelays() {
|
||||||
|
return (this.mailboxes?.outboxes || [])
|
||||||
|
.map(normalizeRelayUrl)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
get configuredReadRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxReadRelays,
|
||||||
|
...(this.settings.nostrReadRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrReadRelayExclusions || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get configuredWriteRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxWriteRelays,
|
||||||
|
...(this.settings.nostrWriteRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrWriteRelayExclusions || []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeReadRelays() {
|
get activeReadRelays() {
|
||||||
if (this.settings.nostrReadRelays) {
|
return mergeRequiredRelays(
|
||||||
return Array.from(
|
this.requiredReadRelays,
|
||||||
new Set(
|
this.configuredReadRelays
|
||||||
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.defaultReadRelays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeWriteRelays() {
|
get activeWriteRelays() {
|
||||||
if (this.settings.nostrWriteRelays) {
|
return mergeRequiredRelays(
|
||||||
return Array.from(
|
this.requiredWriteRelays,
|
||||||
new Set(
|
this.configuredWriteRelays
|
||||||
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.defaultWriteRelays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
@@ -215,7 +240,7 @@ export default class NostrDataService extends Service {
|
|||||||
|
|
||||||
const cachedEvents = await this.cache.query([
|
const cachedEvents = await this.cache.query([
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -236,7 +261,7 @@ export default class NostrDataService extends Service {
|
|||||||
this.nostrRelay.pool
|
this.nostrRelay.pool
|
||||||
.request(this.activeReadRelays, [
|
.request(this.activeReadRelays, [
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
nostrPhotoFallbackUploads: false,
|
nostrPhotoFallbackUploads: false,
|
||||||
nostrReadRelays: null,
|
nostrReadRelays: null,
|
||||||
nostrWriteRelays: null,
|
nostrWriteRelays: null,
|
||||||
|
nostrReadRelayExclusions: null,
|
||||||
|
nostrWriteRelayExclusions: null,
|
||||||
|
experimentalEnablePhotoDeletion: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@@ -20,6 +23,11 @@ export default class SettingsService extends Service {
|
|||||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||||
|
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||||
|
@tracked nostrWriteRelayExclusions =
|
||||||
|
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||||
|
@tracked experimentalEnablePhotoDeletion =
|
||||||
|
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -108,6 +116,10 @@ export default class SettingsService extends Service {
|
|||||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||||
|
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||||
|
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||||
|
this.experimentalEnablePhotoDeletion =
|
||||||
|
finalSettings.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
// Save to ensure migrated settings are stored in the new format
|
// Save to ensure migrated settings are stored in the new format
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
@@ -122,6 +134,9 @@ export default class SettingsService extends Service {
|
|||||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||||
nostrReadRelays: this.nostrReadRelays,
|
nostrReadRelays: this.nostrReadRelays,
|
||||||
nostrWriteRelays: this.nostrWriteRelays,
|
nostrWriteRelays: this.nostrWriteRelays,
|
||||||
|
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||||
|
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||||
|
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||||
};
|
};
|
||||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,6 +460,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden; /* Ensure flex children are contained */
|
overflow: hidden; /* Ensure flex children are contained */
|
||||||
|
will-change: transform;
|
||||||
|
animation: sidebar-slide-in-left 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.app-menu-pane {
|
.sidebar.app-menu-pane {
|
||||||
@@ -1266,6 +1268,20 @@ span.icon {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-with-icon > span:not(.icon) {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-with-icon > span:not(.icon) a {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
.content-with-icon .icon {
|
.content-with-icon .icon {
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1422,8 @@ button.create-place {
|
|||||||
border-top-left-radius: 16px;
|
border-top-left-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 16px;
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
|
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
||||||
|
forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
@@ -1843,6 +1861,42 @@ button.create-place {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions {
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-title {
|
||||||
|
color: #898989;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip.is-selected {
|
||||||
|
background: rgb(255 204 51 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -2087,3 +2141,25 @@ button.create-place {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Snappy slide-in from left (Desktop) */
|
||||||
|
@keyframes sidebar-slide-in-left {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snappy slide-up from bottom (Mobile) */
|
||||||
|
@keyframes sidebar-slide-up-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ export function humanizeOsmTag(text) {
|
|||||||
w.replace(/^\w/, (c) => c.toUpperCase())
|
w.replace(/^\w/, (c) => c.toUpperCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capitalize(text) {
|
||||||
|
if (typeof text !== 'string' || !text) return '';
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function getGeohashPrefixesInBbox(bbox) {
|
|||||||
|
|
||||||
// Safety check to avoid infinite loops or massive arrays if bbox is weird
|
// Safety check to avoid infinite loops or massive arrays if bbox is weird
|
||||||
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
|
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
|
||||||
console.warn(
|
console.debug(
|
||||||
'BBox too large for 4-char geohash scanning, aborting fine scan.'
|
'BBox too large for 4-char geohash scanning, aborting fine scan.'
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
|
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
|
||||||
|
/*
|
||||||
|
* Feather icons
|
||||||
|
*/
|
||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
@@ -6,6 +9,7 @@ import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
|||||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||||
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||||
|
import alertTriangle from 'feather-icons/dist/icons/alert-triangle.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import database from 'feather-icons/dist/icons/database.svg?raw';
|
import database from 'feather-icons/dist/icons/database.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
@@ -39,6 +43,9 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
|
|||||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pinhead icons
|
||||||
|
*/
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||||
@@ -52,8 +59,12 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
|||||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||||
|
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
|
||||||
|
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
|
||||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||||
|
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||||
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
|
import classicalBuildingWithClock from '@waysidemapping/pinhead/dist/icons/classical_building_with_clock.svg?raw';
|
||||||
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
||||||
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
||||||
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
||||||
@@ -81,8 +92,13 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
|
|||||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||||
|
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||||
|
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||||
|
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||||
|
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||||
|
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
@@ -117,6 +133,9 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
|
|||||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Custom/local icons
|
||||||
|
*/
|
||||||
import loadingRing from '../icons/270-ring.svg?raw';
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||||
@@ -143,10 +162,13 @@ const ICONS = {
|
|||||||
'chevron-left': chevronLeft,
|
'chevron-left': chevronLeft,
|
||||||
'chevron-right': chevronRight,
|
'chevron-right': chevronRight,
|
||||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
|
'city-gate': cityGate,
|
||||||
climbing_wall: climbingWall,
|
climbing_wall: climbingWall,
|
||||||
check,
|
check,
|
||||||
'alert-circle': alertCircle,
|
'alert-circle': alertCircle,
|
||||||
|
'alert-triangle': alertTriangle,
|
||||||
'classical-building': classicalBuilding,
|
'classical-building': classicalBuilding,
|
||||||
|
'classical-building-with-clock': classicalBuildingWithClock,
|
||||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
'commercial-building': commercialBuilding,
|
'commercial-building': commercialBuilding,
|
||||||
@@ -183,6 +205,7 @@ const ICONS = {
|
|||||||
'ice-cream-on-cone': iceCreamOnCone,
|
'ice-cream-on-cone': iceCreamOnCone,
|
||||||
'industrial-building': industrialBuilding,
|
'industrial-building': industrialBuilding,
|
||||||
info,
|
info,
|
||||||
|
'info-i': infoI,
|
||||||
instagram,
|
instagram,
|
||||||
jewel,
|
jewel,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
@@ -191,7 +214,11 @@ const ICONS = {
|
|||||||
mail,
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
|
'market-stall': marketStall,
|
||||||
|
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||||
menu,
|
menu,
|
||||||
|
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||||
|
'molar-tooth': molarTooth,
|
||||||
'more-horizontal': moreHorizontal,
|
'more-horizontal': moreHorizontal,
|
||||||
'more-vertical': moreVertical,
|
'more-vertical': moreVertical,
|
||||||
navigation,
|
navigation,
|
||||||
@@ -243,6 +270,8 @@ const ICONS = {
|
|||||||
winding_way_wide: windingWayWide,
|
winding_way_wide: windingWayWide,
|
||||||
parking_p: parkingP,
|
parking_p: parkingP,
|
||||||
car,
|
car,
|
||||||
|
'car-and-wrench': carAndWrench,
|
||||||
|
'castle-keep': castleKeep,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
'loading-ring': loadingRing,
|
'loading-ring': loadingRing,
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ export function normalizeRelayUrl(url) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function uniqNormalizedRelays(relays = []) {
|
||||||
|
return Array.from(new Set(relays.map(normalizeRelayUrl).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRequiredRelays(requiredRelays = [], customRelays = []) {
|
||||||
|
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||||
|
const merged = [...requiredRelays.filter(Boolean)];
|
||||||
|
|
||||||
|
for (const relay of uniqNormalizedRelays(customRelays)) {
|
||||||
|
if (!requiredSet.has(relay)) {
|
||||||
|
merged.push(relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||||
|
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||||
|
return uniqNormalizedRelays(customRelays).filter((relay) => {
|
||||||
|
return !requiredSet.has(relay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||||
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||||
@@ -30,6 +54,11 @@ export function parsePlacePhotos(events) {
|
|||||||
const allPhotos = [];
|
const allPhotos = [];
|
||||||
|
|
||||||
for (const event of sortedEvents) {
|
for (const event of sortedEvents) {
|
||||||
|
const eventTagValues = event.tags
|
||||||
|
.filter((t) => t[0] === 't')
|
||||||
|
.map((t) => t[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
// Find all imeta tags
|
// Find all imeta tags
|
||||||
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||||
for (const imeta of imetas) {
|
for (const imeta of imetas) {
|
||||||
@@ -38,6 +67,7 @@ export function parsePlacePhotos(events) {
|
|||||||
let blurhash = null;
|
let blurhash = null;
|
||||||
let isLandscape = false;
|
let isLandscape = false;
|
||||||
let aspectRatio = 16 / 9; // default
|
let aspectRatio = 16 / 9; // default
|
||||||
|
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
|
||||||
for (const tag of imeta.slice(1)) {
|
for (const tag of imeta.slice(1)) {
|
||||||
if (tag.startsWith('url ')) {
|
if (tag.startsWith('url ')) {
|
||||||
@@ -68,6 +98,8 @@ export function parsePlacePhotos(events) {
|
|||||||
blurhash,
|
blurhash,
|
||||||
isLandscape,
|
isLandscape,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
placeIdentifier,
|
||||||
|
tags: eventTagValues,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const POI_ICON_RULES = [
|
|||||||
|
|
||||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||||
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||||
|
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||||
|
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||||
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||||
@@ -72,6 +74,7 @@ export const POI_ICON_RULES = [
|
|||||||
tags: { shop: 'beauty' },
|
tags: { shop: 'beauty' },
|
||||||
icon: 'fancy-mirror-with-reflection-and-stars',
|
icon: 'fancy-mirror-with-reflection-and-stars',
|
||||||
},
|
},
|
||||||
|
{ tags: { shop: 'car_repair' }, icon: 'car-and-wrench' },
|
||||||
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
{ tags: { office: true }, icon: 'commercial-building' },
|
{ tags: { office: true }, icon: 'commercial-building' },
|
||||||
@@ -103,6 +106,7 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
||||||
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
||||||
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'information' }, icon: 'info-i' },
|
||||||
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
||||||
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
||||||
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
@@ -113,7 +117,9 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
|
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
|
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||||
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||||
|
|||||||
+10
-1
@@ -56,15 +56,24 @@ const PLACE_TYPE_KEYS = [
|
|||||||
export function getPlaceType(tags) {
|
export function getPlaceType(tags) {
|
||||||
if (!tags) return null;
|
if (!tags) return null;
|
||||||
|
|
||||||
|
let fallbackKey = null;
|
||||||
|
|
||||||
for (const key of PLACE_TYPE_KEYS) {
|
for (const key of PLACE_TYPE_KEYS) {
|
||||||
const value = tags[key];
|
const value = tags[key];
|
||||||
if (value) {
|
if (value) {
|
||||||
if (value === 'yes') {
|
if (value === 'yes') {
|
||||||
return humanizeOsmTag(key);
|
if (!fallbackKey) {
|
||||||
|
fallbackKey = key;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return humanizeOsmTag(value);
|
return humanizeOsmTag(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackKey) {
|
||||||
|
return humanizeOsmTag(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { POI_CATEGORIES } from './poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||||
|
|
||||||
|
export const CATEGORY_TAGS = {
|
||||||
|
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
groceries: ['front', 'food'],
|
||||||
|
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||||
|
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSuggestedPhotoTags(place) {
|
||||||
|
const osmTags = place?.osmTags || place?.tags || {};
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
const suggested = [];
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
const tags = CATEGORY_TAGS[categoryId];
|
||||||
|
if (!Array.isArray(tags)) continue;
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!suggested.includes(tag)) {
|
||||||
|
suggested.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggested.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggested;
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
|
|||||||
label: 'Things to do',
|
label: 'Things to do',
|
||||||
icon: 'feather-camera',
|
icon: 'feather-camera',
|
||||||
filter: [
|
filter: [
|
||||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||||
'["historic"]',
|
'["historic"]',
|
||||||
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
|
|||||||
id: 'accommodation',
|
id: 'accommodation',
|
||||||
label: 'Hotels',
|
label: 'Hotels',
|
||||||
icon: 'person-sleeping-in-bed',
|
icon: 'person-sleeping-in-bed',
|
||||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||||
types: ['node', 'way', 'relation'],
|
types: ['node', 'way', 'relation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
export function getMatchingPoiCategories(osmTags, categories) {
|
||||||
|
if (!Array.isArray(categories) || !osmTags) return [];
|
||||||
|
|
||||||
|
return categories.filter((category) => {
|
||||||
|
if (!Array.isArray(category.filter)) return false;
|
||||||
|
return category.filter.some((filterStr) =>
|
||||||
|
matchesFilter(osmTags, filterStr)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingPoiCategoryIds(osmTags, categories) {
|
||||||
|
return getMatchingPoiCategories(osmTags, categories).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(osmTags, filterStr) {
|
||||||
|
const clauses = parseOverpassClauses(filterStr);
|
||||||
|
if (clauses.length === 0) return false;
|
||||||
|
return clauses.every((clause) => matchesClause(osmTags, clause));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOverpassClauses(filterStr) {
|
||||||
|
if (!filterStr) return [];
|
||||||
|
const matches = filterStr.match(/\[[^\]]+\]/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map((raw) => parseClause(raw.slice(1, -1).trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClause(content) {
|
||||||
|
const presenceMatch = content.match(/^"([^"]+)"$/);
|
||||||
|
if (presenceMatch) {
|
||||||
|
return { type: 'presence', key: presenceMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/);
|
||||||
|
if (equalsMatch) {
|
||||||
|
return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/);
|
||||||
|
if (regexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'regex',
|
||||||
|
key: regexMatch[1],
|
||||||
|
pattern: regexMatch[2],
|
||||||
|
regex: new RegExp(regexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/);
|
||||||
|
if (notRegexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'not-regex',
|
||||||
|
key: notRegexMatch[1],
|
||||||
|
pattern: notRegexMatch[2],
|
||||||
|
regex: new RegExp(notRegexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesClause(osmTags, clause) {
|
||||||
|
const tagValues = getTagValues(osmTags, clause.key);
|
||||||
|
|
||||||
|
switch (clause.type) {
|
||||||
|
case 'presence':
|
||||||
|
return tagValues.length > 0;
|
||||||
|
case 'equals':
|
||||||
|
return tagValues.some((value) => value === clause.value);
|
||||||
|
case 'regex':
|
||||||
|
return tagValues.some((value) => clause.regex.test(value));
|
||||||
|
case 'not-regex':
|
||||||
|
return (
|
||||||
|
tagValues.length === 0 ||
|
||||||
|
!tagValues.some((value) => clause.regex.test(value))
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagValues(osmTags, key) {
|
||||||
|
if (!osmTags || !key) return [];
|
||||||
|
const rawValue = osmTags[key];
|
||||||
|
if (rawValue === undefined || rawValue === null) return [];
|
||||||
|
|
||||||
|
return String(rawValue)
|
||||||
|
.split(';')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
<link rel="stylesheet" href="/app/styles/app.css">
|
<link rel="stylesheet" href="/app/styles/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="modal-portal"></div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import Application from './app/app';
|
import Application from './app/app';
|
||||||
import environment from './app/config/environment';
|
import environment from './app/config/environment';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.21.3",
|
"version": "1.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
@@ -39,9 +39,10 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-C_1D7C3-.js"></script>
|
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BmLeTC2Y.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="modal-portal"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class MockOsmService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockStorageService extends Service {
|
class MockStorageService extends Service {
|
||||||
|
initialSyncDone = true;
|
||||||
savedPlaces = [];
|
savedPlaces = [];
|
||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -49,11 +49,19 @@ export class MockNostrDataService extends Service {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultReadRelays() {
|
get requiredReadRelays() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultWriteRelays() {
|
get requiredWriteRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxReadRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxWriteRelays() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -16,7 +16,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/testem.js" integrity="" data-embroider-ignore></script>
|
<script
|
||||||
|
src="/testem.js"
|
||||||
|
integrity=""
|
||||||
|
data-embroider-ignore
|
||||||
|
vite-ignore
|
||||||
|
></script>
|
||||||
|
|
||||||
<script type="module">import "ember-testing";</script>
|
<script type="module">import "ember-testing";</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { click, fillIn, render } from '@ember/test-helpers';
|
||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||||
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
uniqNormalizedRelays,
|
||||||
|
} from 'marco/utils/nostr';
|
||||||
|
|
||||||
|
class MockNostrDataService extends Service {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
requiredReadRelays = ['wss://nostr.kosmos.org'];
|
||||||
|
requiredWriteRelays = [];
|
||||||
|
|
||||||
|
mailboxReadRelays = ['wss://mailbox.example.com'];
|
||||||
|
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
|
||||||
|
|
||||||
|
get configuredReadRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxReadRelays,
|
||||||
|
...(this.settings.nostrReadRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrReadRelayExclusions || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get configuredWriteRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxWriteRelays,
|
||||||
|
...(this.settings.nostrWriteRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrWriteRelayExclusions || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeReadRelays() {
|
||||||
|
return mergeRequiredRelays(
|
||||||
|
this.requiredReadRelays,
|
||||||
|
this.configuredReadRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWriteRelays() {
|
||||||
|
return mergeRequiredRelays(
|
||||||
|
this.requiredWriteRelays,
|
||||||
|
this.configuredWriteRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRows(element) {
|
||||||
|
const list = element.querySelectorAll('.relay-list')[0];
|
||||||
|
return [...list.querySelectorAll('li')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRows(element) {
|
||||||
|
const list = element.querySelectorAll('.relay-list')[1];
|
||||||
|
return [...list.querySelectorAll('li')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowByText(rows, text) {
|
||||||
|
return rows.find((row) => row.textContent.includes(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
localStorage.removeItem('marco:settings');
|
||||||
|
|
||||||
|
this.owner.register('service:nostrData', MockNostrDataService);
|
||||||
|
this.settings = this.owner.lookup('service:settings');
|
||||||
|
this.onChange = () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
localStorage.removeItem('marco:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderAndOpenDetails(context) {
|
||||||
|
await render(
|
||||||
|
<template><AppMenuSettingsNostr @onChange={{this.onChange}} /></template>
|
||||||
|
);
|
||||||
|
await click('summary');
|
||||||
|
return context.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('required read relay is first and non-removable', async function (assert) {
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
const rows = readRows(element);
|
||||||
|
|
||||||
|
assert.dom(rows[0]).includesText('nostr.kosmos.org');
|
||||||
|
|
||||||
|
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||||
|
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||||
|
|
||||||
|
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||||
|
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||||
|
|
||||||
|
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||||
|
|
||||||
|
assert.deepEqual(this.settings.nostrReadRelayExclusions, [
|
||||||
|
'wss://mailbox.example.com',
|
||||||
|
]);
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing custom read relay updates custom list without exclusions', async function (assert) {
|
||||||
|
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||||
|
|
||||||
|
await click(customRow.querySelector('.btn-remove-relay'));
|
||||||
|
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding read relay clears existing exclusion for same relay', async function (assert) {
|
||||||
|
this.settings.update('nostrReadRelayExclusions', [
|
||||||
|
'wss://mailbox.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
|
||||||
|
await fillIn('#new-read-relay', 'Mailbox.EXAMPLE.com/');
|
||||||
|
await click(element.querySelector('#new-read-relay').nextElementSibling);
|
||||||
|
|
||||||
|
assert.deepEqual(this.settings.nostrReadRelays, [
|
||||||
|
'wss://mailbox.example.com',
|
||||||
|
]);
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset read relays clears additions and exclusions', async function (assert) {
|
||||||
|
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||||
|
this.settings.update('nostrReadRelayExclusions', [
|
||||||
|
'wss://mailbox.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
await click(element.querySelectorAll('.reset-relays')[0]);
|
||||||
|
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelays, null);
|
||||||
|
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
|
||||||
|
|
||||||
|
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||||
|
assert.dom(requiredRow).exists();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write relays are removable and mailbox delete stores exclusion', async function (assert) {
|
||||||
|
this.settings.update('nostrWriteRelays', [
|
||||||
|
'wss://custom-write.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
const rows = writeRows(element);
|
||||||
|
|
||||||
|
assert.true(
|
||||||
|
rows.every((row) => row.querySelector('.btn-remove-relay')),
|
||||||
|
'all write relays can be removed'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mailboxRow = rowByText(rows, 'mailbox-write.example.com');
|
||||||
|
await click(mailboxRow.querySelector('.btn-remove-relay'));
|
||||||
|
|
||||||
|
assert.deepEqual(this.settings.nostrWriteRelayExclusions, [
|
||||||
|
'wss://mailbox-write.example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PhotoGallery from 'marco/components/photo-gallery';
|
||||||
|
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
class MockBlossomService extends Service {
|
||||||
|
async delete() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockToastService extends Service {
|
||||||
|
show() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Integration | Component | photo-gallery', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:blossom', MockBlossomService);
|
||||||
|
this.owner.register('service:toast', MockToastService);
|
||||||
|
|
||||||
|
this.blossom = this.owner.lookup('service:blossom');
|
||||||
|
this.nostrAuth = this.owner.lookup('service:nostrAuth');
|
||||||
|
this.nostrData = this.owner.lookup('service:nostrData');
|
||||||
|
this.nostrRelay = this.owner.lookup('service:nostrRelay');
|
||||||
|
this.toast = this.owner.lookup('service:toast');
|
||||||
|
this.settings = this.owner.lookup('service:settings');
|
||||||
|
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
eventId: 'event1',
|
||||||
|
pubkey: 'userA',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
thumbUrl:
|
||||||
|
'https://example.com/b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'event2',
|
||||||
|
pubkey: 'userB',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'photo2.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
localStorage.removeItem('marco:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not show delete button if user is not creator', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.doesNotExist('Delete button is hidden for non-creator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.exists('Delete button is visible for creator when setting is enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles cancellation of deletion', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(false);
|
||||||
|
const blossomSpy = sinon.spy(this.blossom, 'delete');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
// Override the mock's getter just for this test
|
||||||
|
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
signEvent: async (e) => ({
|
||||||
|
...e,
|
||||||
|
id: 'signed-id',
|
||||||
|
sig: 'sig',
|
||||||
|
pubkey: 'userA',
|
||||||
|
}),
|
||||||
|
getPublicKey: async () => 'userA',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||||
|
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||||
|
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||||
|
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
|
||||||
|
// Check blossom deletions
|
||||||
|
assert.ok(
|
||||||
|
blossomStub.calledTwice,
|
||||||
|
'blossom.delete was called twice (main + thumb)'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.firstCall.args[0],
|
||||||
|
'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for main image'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.secondCall.args[0],
|
||||||
|
'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for thumb image'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Nostr kind 5
|
||||||
|
assert.ok(publishStub.calledOnce, 'nostrRelay.publish was called');
|
||||||
|
const publishedEvent = publishStub.firstCall.args[1];
|
||||||
|
assert.strictEqual(publishedEvent.kind, 5, 'published event is kind 5');
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[0],
|
||||||
|
['e', 'event1'],
|
||||||
|
'event tags reference the deleted photo'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[1],
|
||||||
|
['i', 'osm:node:12345'],
|
||||||
|
'event tags include the place identifier'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check store update
|
||||||
|
assert.ok(storeStub.calledOnce, 'nostrData.store.add was called');
|
||||||
|
assert.strictEqual(
|
||||||
|
storeStub.firstCall.args[0].kind,
|
||||||
|
5,
|
||||||
|
'added kind 5 event to local store'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check UX
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Photo deleted successfully'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
assert.ok(closed, 'gallery was closed after deletion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it copies event id to clipboard', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const clipboardStub = sinon
|
||||||
|
.stub(navigator.clipboard, 'writeText')
|
||||||
|
.resolves();
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
// Find the copy button (it should be the first one)
|
||||||
|
const items = document.querySelectorAll('.dropdown-item');
|
||||||
|
let copyBtn;
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.textContent.includes('Copy Photo Event ID')) {
|
||||||
|
copyBtn = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await click(copyBtn);
|
||||||
|
|
||||||
|
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation changes photos', async function (assert) {
|
||||||
|
this.photos = [
|
||||||
|
{ eventId: 'event1', url: 'photo1.jpg' },
|
||||||
|
{ eventId: 'event2', url: 'photo2.jpg' },
|
||||||
|
{ eventId: 'event3', url: 'photo3.jpg' },
|
||||||
|
];
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event1');
|
||||||
|
|
||||||
|
// Right Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
|
||||||
|
// Right Arrow again
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event3');
|
||||||
|
|
||||||
|
// Left Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escape key closes gallery', async function (assert) {
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||||
|
assert.ok(closed, 'gallery was closed on escape key');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -334,4 +334,54 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
assert.dom(links[0]).hasText('+44 987 654 321');
|
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||||
assert.dom(links[1]).hasText('+1 234-567 8900');
|
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'OSM Place',
|
||||||
|
osmId: '12345',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.520008,
|
||||||
|
lon: 13.404954,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const osmLink = this.element.querySelector(
|
||||||
|
'.meta-info a[href^="https://www.openstreetmap.org/node/12345"]'
|
||||||
|
);
|
||||||
|
assert.ok(osmLink, 'OpenStreetMap link is rendered');
|
||||||
|
assert.strictEqual(
|
||||||
|
osmLink.getAttribute('href'),
|
||||||
|
'https://www.openstreetmap.org/node/12345'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('button.btn-link').hasText('Add a photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders correct search-based OpenStreetMap link for a custom saved place', async function (assert) {
|
||||||
|
class MockMapUi extends Service {
|
||||||
|
currentCenter = { lat: 52.5, lon: 13.4 };
|
||||||
|
currentZoom = 15.6;
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUi);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
title: 'Custom Place',
|
||||||
|
lat: 52.520008,
|
||||||
|
lon: 13.404954,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const osmLink = this.element.querySelector(
|
||||||
|
'.meta-info a[href^="https://www.openstreetmap.org/search"]'
|
||||||
|
);
|
||||||
|
assert.ok(osmLink, 'OpenStreetMap search link is rendered');
|
||||||
|
assert.strictEqual(
|
||||||
|
osmLink.getAttribute('href'),
|
||||||
|
'https://www.openstreetmap.org/search?lat=52.520008&lon=13.404954&zoom=16#map=16/52.50000/13.40000'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('button.btn-link').doesNotExist();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PlacePhotoUpload from 'marco/components/place-photo-upload';
|
||||||
|
|
||||||
|
module('Integration | Component | place-photo-upload', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class MockNostrAuthService extends Service {
|
||||||
|
get isConnected() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectFile(element, file) {
|
||||||
|
const input = element.querySelector('#photo-upload-input');
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: [file],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(input, 'change');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('it shows tag suggestions when they exist 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-tag-suggestions').doesNotExist();
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').exists();
|
||||||
|
assert.dom('.photo-tag-chip').exists();
|
||||||
|
assert.dom('.photo-tag-chip').includesText('Food');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it only allows one selected tag at a time', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
const chips = this.element.querySelectorAll('.photo-tag-chip');
|
||||||
|
assert.ok(chips.length > 1, 'multiple tag chips are rendered');
|
||||||
|
|
||||||
|
await click(chips[0]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[0]).hasClass('is-selected');
|
||||||
|
|
||||||
|
await click(chips[1]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[1]).hasClass('is-selected');
|
||||||
|
assert.dom(chips[0]).doesNotHaveClass('is-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it hides tag suggestions when no tags are suggested', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Office Beta',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { office: 'lawyer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
|
import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers';
|
||||||
import SearchBox from 'marco/components/search-box';
|
import SearchBox from 'marco/components/search-box';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
|
||||||
@@ -208,22 +208,301 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Type "Resta" to trigger "Restaurants" category match
|
// Type "Resta" to trigger "Restaurants" category match
|
||||||
|
await focus('.search-input');
|
||||||
await fillIn('.search-input', 'Resta');
|
await fillIn('.search-input', 'Resta');
|
||||||
|
|
||||||
// Wait for debounce (300ms) + execution
|
await waitFor('.search-result-item');
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
await delay(400);
|
|
||||||
|
|
||||||
// The first result should be the category match
|
const resultItems = Array.from(
|
||||||
assert.dom('.search-result-item').exists({ count: 1 });
|
this.element.querySelectorAll('.search-result-item')
|
||||||
assert.dom('.result-title').hasText('Restaurants');
|
);
|
||||||
|
const categoryResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('Restaurants')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryResult, 'Restaurants category result is shown');
|
||||||
|
|
||||||
// Click the result
|
// Click the result
|
||||||
await click('.search-result-item');
|
await click(categoryResult);
|
||||||
|
|
||||||
// Assert transition with lat/lon from map center
|
// Assert transition with lat/lon from map center
|
||||||
assert.verifySteps([
|
assert.verifySteps([
|
||||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'coffee') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'Duplicate to be removed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Other Coffee',
|
||||||
|
osmId: '888',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'A different coffee shop',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "coffee" to trigger matches in Category, Saved, and Photon
|
||||||
|
await fillIn('.search-input', 'coffee');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should be exactly 3 items:
|
||||||
|
// 1. Category (Coffee)
|
||||||
|
// 2. Saved (Awesome Coffee)
|
||||||
|
// 3. Photon (Other Coffee)
|
||||||
|
// (The Photon duplicate of "Awesome Coffee" is removed)
|
||||||
|
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
|
||||||
|
|
||||||
|
// 1. Category
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Coffee'),
|
||||||
|
'First item is the category match'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[0].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-search', 'Category uses search icon');
|
||||||
|
|
||||||
|
// 2. Saved Place
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Awesome Coffee'),
|
||||||
|
'Second item is the saved place match'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Saved place'),
|
||||||
|
'Saved place has correct description text'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[1].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
|
||||||
|
|
||||||
|
// 3. Photon Match
|
||||||
|
assert.ok(
|
||||||
|
resultItems[2].textContent.includes('Other Coffee'),
|
||||||
|
'Third item is the unique photon result'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the Saved Place
|
||||||
|
await click(resultItems[1]);
|
||||||
|
|
||||||
|
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it requires 3 or more characters to match saved places', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'aw' || query === 'awe') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Aww Some Place',
|
||||||
|
osmId: '111',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "aw" (2 characters)
|
||||||
|
await fillIn('.search-input', 'aw');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
let resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only show Photon match since 'aw' is < 3 characters
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
1,
|
||||||
|
'Renders exactly 1 item for 2 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Aww Some Place'),
|
||||||
|
'Shows photon match'
|
||||||
|
);
|
||||||
|
assert.notOk(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is NOT shown for 2 char query'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "awe" (3 characters)
|
||||||
|
await fillIn('.search-input', 'awe');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should now show Saved Place and Photon match
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
2,
|
||||||
|
'Renders exactly 2 items for 3 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is now shown'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) =>
|
||||||
|
item.textContent.includes('Saved place (Favorites)')
|
||||||
|
),
|
||||||
|
'List names are appended to the description'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service (Custom Place)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
id: 'custom-1234',
|
||||||
|
title: 'My Custom Home',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
// Notice NO osmId or osmType
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type 3 chars to trigger saved place match
|
||||||
|
await fillIn('.search-input', 'cus');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure our custom place is rendered
|
||||||
|
const customResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('My Custom Home')
|
||||||
|
);
|
||||||
|
assert.ok(customResult, 'Custom place is rendered');
|
||||||
|
|
||||||
|
// Click it
|
||||||
|
await click(customResult);
|
||||||
|
|
||||||
|
// Verify it navigated using the internal ID, NOT a search query
|
||||||
|
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
parsePlacePhotos,
|
||||||
|
uniqNormalizedRelays,
|
||||||
|
} from 'marco/utils/nostr';
|
||||||
|
|
||||||
|
module('Unit | Utility | nostr', function () {
|
||||||
|
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
|
||||||
|
assert.strictEqual(normalizeRelayUrl(null), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(''), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(' '), '');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('Relay.example.com'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('ws://Relay.example.com/'),
|
||||||
|
'ws://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('wss://relay.example.com///'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos includes event t tags on photo objects', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 123,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['t', 'food'],
|
||||||
|
['t', 'vibe'],
|
||||||
|
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.deepEqual(photos[0].tags, ['food', 'vibe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos sorts by created_at', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/late.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/early.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/early.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/late.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos skips imeta entries without urls', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [['imeta', 'dim 800x600']],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.deepEqual(photos, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos returns one photo per event imeta tag', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
|
||||||
|
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||||
|
const relays = uniqNormalizedRelays([
|
||||||
|
'Relay.example.com',
|
||||||
|
'wss://relay.example.com/',
|
||||||
|
'wss://other.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(relays, [
|
||||||
|
'wss://relay.example.com',
|
||||||
|
'wss://other.example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeRequiredRelays keeps required relays as-is and merges normalized custom relays', function (assert) {
|
||||||
|
const relays = mergeRequiredRelays(
|
||||||
|
['wss://required.example.com', 'required-2.example.com'],
|
||||||
|
['required-2.example.com/', 'wss://custom.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(relays, [
|
||||||
|
'wss://required.example.com',
|
||||||
|
'required-2.example.com',
|
||||||
|
'wss://required-2.example.com',
|
||||||
|
'wss://custom.example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludeRequiredRelays removes required relays from normalized custom list', function (assert) {
|
||||||
|
const relays = excludeRequiredRelays(
|
||||||
|
[
|
||||||
|
'wss://required.example.com',
|
||||||
|
'custom.example.com',
|
||||||
|
'ws://custom2.example.com',
|
||||||
|
],
|
||||||
|
['wss://required.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(relays, [
|
||||||
|
'wss://custom.example.com',
|
||||||
|
'ws://custom2.example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludeRequiredRelays trusts required list without normalizing it', function (assert) {
|
||||||
|
const relays = excludeRequiredRelays(
|
||||||
|
['Required.example.com', 'custom.example.com'],
|
||||||
|
['required.example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(relays, [
|
||||||
|
'wss://required.example.com',
|
||||||
|
'wss://custom.example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getIconNameForTags } from 'marco/utils/osm-icons';
|
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons';
|
||||||
|
import { getIcon } from 'marco/utils/icons';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
module('Unit | Utility | osm-icons', function () {
|
module('Unit | Utility | osm-icons', function () {
|
||||||
@@ -36,4 +37,14 @@ module('Unit | Utility | osm-icons', function () {
|
|||||||
let result = getIconNameForTags({ foo: 'bar' });
|
let result = getIconNameForTags({ foo: 'bar' });
|
||||||
assert.strictEqual(result, null);
|
assert.strictEqual(result, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
||||||
|
for (let rule of POI_ICON_RULES) {
|
||||||
|
let icon = getIcon(rule.icon);
|
||||||
|
assert.ok(
|
||||||
|
icon,
|
||||||
|
`Icon "${rule.icon}" specified in POI_ICON_RULES should be imported and available in getIcon`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ module('Unit | Utility | osm', function (hooks) {
|
|||||||
assert.strictEqual(result, 'Building');
|
assert.strictEqual(result, 'Building');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getPlaceType ignores generic "yes" values if a more specific tag is present', function (assert) {
|
||||||
|
const tags = { historic: 'yes', building: 'tower' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Tower');
|
||||||
|
});
|
||||||
|
|
||||||
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||||
// If something is both a shop and a building, it should be a shop
|
// If something is both a shop and a building, it should be a shop
|
||||||
const tags = { building: 'yes', shop: 'supermarket' };
|
const tags = { building: 'yes', shop: 'supermarket' };
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher';
|
||||||
|
import {
|
||||||
|
getSuggestedPhotoTags,
|
||||||
|
CATEGORY_TAGS,
|
||||||
|
} from 'marco/utils/photo-tag-suggestions';
|
||||||
|
|
||||||
|
module('Unit | Utility | photo-tag-suggestions', function () {
|
||||||
|
test('returns tags for all matching categories with de-duplication', function (assert) {
|
||||||
|
const place = { osmTags: { amenity: 'cafe' } };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(
|
||||||
|
place.osmTags,
|
||||||
|
POI_CATEGORIES
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
assert.deepEqual(result, CATEGORY_TAGS.restaurants);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns no tags when no category matches', function (assert) {
|
||||||
|
const place = { osmTags: { office: 'lawyer' } };
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import {
|
||||||
|
getMatchingPoiCategories,
|
||||||
|
getMatchingPoiCategoryIds,
|
||||||
|
} from 'marco/utils/poi-category-matcher';
|
||||||
|
|
||||||
|
module('Unit | Utility | poi-category-matcher', function () {
|
||||||
|
test('matches multiple categories from OSM tags', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports semicolon-separated values', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe;bar' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative regex clause fails if any value matches', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe', cuisine: 'coffee;irish' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.notOk(categoryIds.includes('restaurants'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presence clause matches when tag exists', function (assert) {
|
||||||
|
const tags = { historic: 'castle' };
|
||||||
|
const categories = getMatchingPoiCategories(tags, POI_CATEGORIES);
|
||||||
|
const categoryIds = categories.map((category) => category.id);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('things-to-do'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user