Compare commits
66 Commits
a89ba904c8
...
v1.21.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
4c4a53ae42
|
|||
|
a240a5d199
|
|||
|
0332cf4c3c
|
|||
|
59c447fe1f
|
|||
|
1140ecfe41
|
|||
|
60936ed2f5
|
|||
|
ca82a029bc
|
|||
|
0630aed73d
|
|||
|
f27a636529
|
|||
|
995ae95b09
|
|||
|
0fb320d996
|
|||
|
4f4ca827b1
|
|||
|
c1d3f25d50
|
|||
|
2087cfc4f7
|
|||
|
8572032481
|
|||
|
b4c3f5c88d
|
|||
|
cff19980d5
|
|||
|
cf251f702b
|
|||
|
d2eb888dcf
|
|||
|
a0b4a4b3f3
|
|||
|
cb3ee48909
|
|||
|
1d022b21bd
|
|||
|
3e831a7686
|
|||
|
2943125dbd
|
|||
|
a32ad7572b
|
|||
|
a1b3957c83
|
|||
|
9f2f233c22
|
|||
|
1ba4afdf08
|
|||
|
d764134513
|
|||
|
e38f540c79
|
|||
|
73ad5b4eb1
|
|||
|
b4a70233cf
|
|||
|
cb4b9c6b40
|
|||
|
98dcb4f25b
|
|||
|
7709634a9a
|
|||
|
3ddc85669f
|
|||
|
95961e680f
|
|||
|
9468a6a0cc
|
|||
|
c9465c8fa8
|
|||
|
6c5c1fea27
|
|||
|
fe41369754
|
|||
|
1498c5a713
|
|||
|
b6e2964f8e
|
|||
|
d1d179bb93
|
|||
|
b83a16bf13
|
|||
|
c853418fbb
|
|||
|
4fed8c05c5
|
|||
|
670128cbda
|
|||
|
d8fa30c74b
|
|||
|
0f8d7046ac
|
|||
|
8ca7481a79
|
|||
|
cd25c55bd7
|
|||
|
32c4f7da57
|
|||
|
71939a30c3
|
|||
|
7285ace882
|
|||
|
94ba33ecc1
|
|||
|
85a8699b78
|
|||
|
99cfd96ca1
|
|||
|
8d40b3bb35
|
|||
|
c5316bf336
|
|||
|
a384e83dd0
|
|||
|
b23d54d74f
|
|||
|
5bd4dba907
|
|||
|
54ba99673f
|
|||
|
54445f249b
|
|||
|
9828ad2714
|
@@ -8,12 +8,14 @@ import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service mapUi;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
@@ -21,6 +23,11 @@ export default class AppHeaderComponent extends Component {
|
||||
return !!this.searchQuery;
|
||||
}
|
||||
|
||||
get showQuickSearch() {
|
||||
const zoom = this.mapUi.currentZoom ?? 13;
|
||||
return this.settings.showQuickSearchButtons && zoom >= 12;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleUserMenu() {
|
||||
this.isUserMenuOpen = !this.isUserMenuOpen;
|
||||
@@ -53,7 +60,7 @@ export default class AppHeaderComponent extends Component {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.settings.showQuickSearchButtons}}
|
||||
{{#if this.showQuickSearch}}
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
@@ -71,7 +78,7 @@ export default class AppHeaderComponent extends Component {
|
||||
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
|
||||
}}
|
||||
<img
|
||||
src={{this.nostrData.profile.picture}}
|
||||
{{cachedImage this.nostrData.profile.picture}}
|
||||
class="user-avatar"
|
||||
alt="User Avatar"
|
||||
/>
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import AppMenuSettingsMapUi from './settings/map-ui';
|
||||
import AppMenuSettingsApis from './settings/apis';
|
||||
import AppMenuSettingsNostr from './settings/nostr';
|
||||
|
||||
export default class AppMenuSettings extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
updateSetting(key, event) {
|
||||
let value = event.target.value;
|
||||
if (value === 'true') value = true;
|
||||
if (value === 'false') value = false;
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
toggleQuickSearchButtons(event) {
|
||||
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
updatePhotonApi(event) {
|
||||
this.settings.updatePhotonApi(event.target.value);
|
||||
this.settings.update(key, value);
|
||||
}
|
||||
|
||||
<template>
|
||||
@@ -41,88 +32,9 @@ export default class AppMenuSettings extends Component {
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<div class="form-group">
|
||||
<label for="show-quick-search">Quick search buttons visible</label>
|
||||
<select
|
||||
id="show-quick-search"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleQuickSearchButtons}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.showQuickSearchButtons
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updateApi}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="photon-api">Photon API Provider</label>
|
||||
<select
|
||||
id="photon-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updatePhotonApi}}
|
||||
>
|
||||
{{#each this.settings.photonApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
59
app/components/app-menu/settings/apis.gjs
Normal file
59
app/components/app-menu/settings/apis.gjs
Normal file
@@ -0,0 +1,59 @@
|
||||
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';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class AppMenuSettingsApis extends Component {
|
||||
@service settings;
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="server" @size={{20}} />
|
||||
<span>API Providers</span>
|
||||
</summary>
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "overpassApi")}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="photon-api">Photon API Provider</label>
|
||||
<select
|
||||
id="photon-api"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "photonApi")}}
|
||||
>
|
||||
{{#each this.settings.photonApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
66
app/components/app-menu/settings/map-ui.gjs
Normal file
66
app/components/app-menu/settings/map-ui.gjs
Normal file
@@ -0,0 +1,66 @@
|
||||
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 AppMenuSettingsMapUi extends Component {
|
||||
@service settings;
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="map" @size={{20}} />
|
||||
<span>Map & UI</span>
|
||||
</summary>
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="show-quick-search">Quick search buttons visible</label>
|
||||
<select
|
||||
id="show-quick-search"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "showQuickSearchButtons")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.showQuickSearchButtons
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "mapKinetic")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
242
app/components/app-menu/settings/nostr.gjs
Normal file
242
app/components/app-menu/settings/nostr.gjs
Normal file
@@ -0,0 +1,242 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
import { normalizeRelayUrl } from '../../../utils/nostr';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
export default class AppMenuSettingsNostr extends Component {
|
||||
@service settings;
|
||||
@service nostrData;
|
||||
@service toast;
|
||||
|
||||
@tracked newReadRelay = '';
|
||||
@tracked newWriteRelay = '';
|
||||
|
||||
@action
|
||||
updateNewReadRelay(event) {
|
||||
this.newReadRelay = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateNewWriteRelay(event) {
|
||||
this.newWriteRelay = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
addReadRelay() {
|
||||
const url = normalizeRelayUrl(this.newReadRelay);
|
||||
if (!url) return;
|
||||
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrReadRelays', Array.from(set));
|
||||
this.newReadRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeReadRelay(url) {
|
||||
const current =
|
||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrReadRelays', filtered);
|
||||
}
|
||||
|
||||
@action
|
||||
handleReadRelayKeydown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
this.addReadRelay();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleWriteRelayKeydown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
this.addWriteRelay();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
resetReadRelays() {
|
||||
this.settings.update('nostrReadRelays', null);
|
||||
}
|
||||
|
||||
@action
|
||||
addWriteRelay() {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
if (!url) return;
|
||||
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const set = new Set([...current, url]);
|
||||
this.settings.update('nostrWriteRelays', Array.from(set));
|
||||
this.newWriteRelay = '';
|
||||
}
|
||||
|
||||
@action
|
||||
removeWriteRelay(url) {
|
||||
const current =
|
||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||
const filtered = current.filter((r) => r !== url);
|
||||
this.settings.update('nostrWriteRelays', filtered);
|
||||
}
|
||||
|
||||
@action
|
||||
resetWriteRelays() {
|
||||
this.settings.update('nostrWriteRelays', null);
|
||||
}
|
||||
|
||||
@action
|
||||
async clearCache() {
|
||||
try {
|
||||
await this.nostrData.clearCache();
|
||||
this.toast.show('Nostr cache cleared');
|
||||
} catch (e) {
|
||||
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="zap" @size={{20}} />
|
||||
<span>Nostr</span>
|
||||
</summary>
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeReadRelays as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeReadRelay relay)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-read-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newReadRelay}}
|
||||
{{on "input" this.updateNewReadRelay}}
|
||||
{{on "keydown" this.handleReadRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrReadRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetReadRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeWriteRelays as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
{{on "click" (fn this.removeWriteRelay relay)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrWriteRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cached data</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-full"
|
||||
{{on "click" this.clearCache}}
|
||||
>
|
||||
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
|
||||
Clear profiles, photos, and reviews
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
}
|
||||
37
app/components/blurhash.gjs
Normal file
37
app/components/blurhash.gjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
export default class Blurhash extends Component {
|
||||
renderBlurhash = modifier((canvas, [hash, width, height]) => {
|
||||
if (!hash || !canvas) return;
|
||||
|
||||
// Default size to a small multiple of aspect ratio to save CPU
|
||||
// 32x18 is a good balance of speed vs quality for 16:9
|
||||
const renderWidth = width || 32;
|
||||
const renderHeight = height || 18;
|
||||
|
||||
canvas.width = renderWidth;
|
||||
canvas.height = renderHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const pixels = decode(hash, renderWidth, renderHeight);
|
||||
const imageData = ctx.createImageData(renderWidth, renderHeight);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode blurhash:', e.message || e);
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
class="blurhash-canvas"
|
||||
...attributes
|
||||
{{this.renderBlurhash @hash @width @height}}
|
||||
></canvas>
|
||||
</template>
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default class MapComponent extends Component {
|
||||
@service mapUi;
|
||||
@service router;
|
||||
@service settings;
|
||||
@service nostrData;
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
@@ -283,6 +284,7 @@ export default class MapComponent extends Component {
|
||||
// Initialize the UI service with the map center
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
this.mapUi.updateZoom(view.getZoom());
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||
webfonts: 'data:text/css,',
|
||||
@@ -781,14 +783,19 @@ export default class MapComponent extends Component {
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
if (size[0] <= 768) {
|
||||
// On mobile, the bottom 50% is covered by the sheet.
|
||||
// We want the pin to be in the center of the TOP 50% (visible area).
|
||||
// That means the pin should be at y = height * 0.25 (25% down from top).
|
||||
// The map center is at y = height * 0.50.
|
||||
// So the pin is "above" the center by 25% of the height in pixels.
|
||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||
// We want the pin to be in the center of the TOP 50% (visible area), minus the header.
|
||||
|
||||
const height = size[1];
|
||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
||||
const headerEl = document.querySelector('.app-header');
|
||||
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||
|
||||
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
|
||||
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
|
||||
|
||||
// The map center is at y = height * 0.50.
|
||||
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
|
||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// Shift center SOUTH (decrease Y).
|
||||
@@ -848,6 +855,9 @@ export default class MapComponent extends Component {
|
||||
let targetPixelY = pixel[1];
|
||||
let needsPan = false;
|
||||
|
||||
const headerEl = document.querySelector('.app-header');
|
||||
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||
|
||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||
if (size[0] <= 768) {
|
||||
const height = size[1];
|
||||
@@ -855,7 +865,7 @@ export default class MapComponent extends Component {
|
||||
|
||||
// If in bottom half
|
||||
if (pixel[1] > splitPoint) {
|
||||
targetPixelY = height * 0.25; // Target: Center of top half
|
||||
targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
@@ -876,11 +886,10 @@ export default class MapComponent extends Component {
|
||||
|
||||
// 3. Header Logic (Any screen size)
|
||||
// Check if the (potentially new) target Y is under the header
|
||||
const headerHeight = 60;
|
||||
const minTopDistance = headerHeight + 20; // 80px
|
||||
const minTopDistance = headerHeight + 20; // Provide some padding
|
||||
|
||||
if (targetPixelY < minTopDistance) {
|
||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||
targetPixelY = minTopDistance + 30; // Move it clear of header
|
||||
needsPan = true;
|
||||
}
|
||||
|
||||
@@ -1033,11 +1042,12 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(center[1], center[0]);
|
||||
this.mapUi.updateZoom(view.getZoom());
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
@@ -1078,6 +1088,7 @@ export default class MapComponent extends Component {
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
this.mapUi.updateBounds(bbox);
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
this.nostrData.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.placesInView);
|
||||
|
||||
// Persist view to localStorage
|
||||
@@ -1142,6 +1153,8 @@ export default class MapComponent extends Component {
|
||||
this.mapUi.returnToSearch = true;
|
||||
}
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('place', place);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export default class Modal extends Component {
|
||||
|
||||
@action
|
||||
close() {
|
||||
if (this.args.disableClose) return;
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
@@ -31,10 +32,11 @@ export default class Modal extends Component {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text"
|
||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||
disabled={{@disableClose}}
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} />
|
||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,40 @@ export default class NostrConnectComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async copyConnectUri() {
|
||||
const text = this.nostrAuth.connectUri;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
textArea.style.opacity = '0';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (!successful) {
|
||||
throw new Error('Fallback copy failed');
|
||||
}
|
||||
}
|
||||
this.toast.show('Connection link copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
alert('Failed to copy link');
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="nostr-connect-modal">
|
||||
<h2>Connect with Nostr</h2>
|
||||
@@ -59,7 +93,7 @@ export default class NostrConnectComponent extends Component {
|
||||
class="btn btn-outline"
|
||||
type="button"
|
||||
disabled
|
||||
title="No Nostr extension found in your browser."
|
||||
title="No Nostr extension found in your browser"
|
||||
>
|
||||
Browser Extension (Not Found)
|
||||
</button>
|
||||
@@ -79,9 +113,20 @@ export default class NostrConnectComponent extends Component {
|
||||
{{#if this.nostrAuth.isMobile}}
|
||||
<p>Waiting for you to approve the connection in your mobile signer
|
||||
app...</p>
|
||||
<div class="mobile-connect-actions">
|
||||
<a href={{this.nostrAuth.connectUri}} class="btn btn-primary">
|
||||
Open Signer App
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
type="button"
|
||||
{{on "click" this.copyConnectUri}}
|
||||
>
|
||||
Copy Connection Link
|
||||
</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>Scan this QR code with a compatible Nostr signer app (like
|
||||
Amber):</p>
|
||||
<p>Scan this QR code with a Nostr signer app (like Amber):</p>
|
||||
<div class="qr-code-container">
|
||||
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
||||
</div>
|
||||
|
||||
272
app/components/photo-carousel.gjs
Normal file
272
app/components/photo-carousel.gjs
Normal file
@@ -0,0 +1,272 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { fn } from '@ember/helper';
|
||||
import { and, eq } from 'ember-truth-helpers';
|
||||
import Blurhash from './blurhash';
|
||||
import Icon from './icon';
|
||||
import fadeInImage from '../modifiers/fade-in-image';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default class PhotoCarousel extends Component {
|
||||
@tracked canScrollLeft = false;
|
||||
@tracked canScrollRight = false;
|
||||
|
||||
internalEventId = null;
|
||||
carouselElement = null;
|
||||
|
||||
get photos() {
|
||||
return this.args.photos || [];
|
||||
}
|
||||
|
||||
get showChevrons() {
|
||||
// Only show chevrons if there's more than one photo
|
||||
return this.photos.length > 1;
|
||||
}
|
||||
|
||||
get cannotScrollLeft() {
|
||||
return !this.canScrollLeft;
|
||||
}
|
||||
|
||||
get cannotScrollRight() {
|
||||
return !this.canScrollRight;
|
||||
}
|
||||
|
||||
get variantClass() {
|
||||
return this.args.variant || 'inline';
|
||||
}
|
||||
|
||||
lastResetKey = null;
|
||||
|
||||
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
|
||||
this.lastResetKey = resetKey;
|
||||
element.scrollLeft = 0;
|
||||
setTimeout(() => this.updateScrollState(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
const isInitial = !this.lastEventId;
|
||||
this.lastEventId = eventId;
|
||||
|
||||
// Prevent feedback loop if this carousel initiated the change
|
||||
if (this.internalEventId === eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollAction = () => {
|
||||
const targetSlide = element.querySelector(
|
||||
`[data-event-id="${eventId}"]`
|
||||
);
|
||||
if (targetSlide) {
|
||||
if (isInitial) {
|
||||
const originalScrollBehavior = element.style.scrollBehavior;
|
||||
element.style.scrollBehavior = 'auto';
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
|
||||
// Restore smooth scroll after the jump
|
||||
setTimeout(() => {
|
||||
element.style.scrollBehavior = originalScrollBehavior;
|
||||
}, 50);
|
||||
} else {
|
||||
// Use native CSS smooth scrolling for subsequent clicks
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isInitial) {
|
||||
// Execute immediately for the first render to prevent flash
|
||||
scrollAction();
|
||||
} else {
|
||||
// Allow DOM to update first for subsequent clicks
|
||||
setTimeout(scrollAction, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setupCarousel = modifier((element) => {
|
||||
this.carouselElement = element;
|
||||
|
||||
// Defer the initial calculation slightly to ensure CSS and images have applied
|
||||
setTimeout(() => {
|
||||
this.updateScrollState();
|
||||
}, 50);
|
||||
|
||||
let resizeObserver;
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
let intersectionObserver;
|
||||
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
|
||||
// Set up intersection observer to track which photo is currently "most" visible
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (let entry of entries) {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||
const eventId = entry.target.dataset.eventId;
|
||||
this.internalEventId = eventId;
|
||||
const photo = this.photos.find((p) => p.eventId === eventId);
|
||||
if (photo) {
|
||||
this.args.onVisiblePhotoChange(photo);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: element,
|
||||
threshold: 0.5,
|
||||
}
|
||||
);
|
||||
|
||||
const slides = element.querySelectorAll('.carousel-slide');
|
||||
slides.forEach((slide) => intersectionObserver.observe(slide));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.unobserve(element);
|
||||
}
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
updateScrollState() {
|
||||
if (!this.carouselElement) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
|
||||
// tolerance of 1px for floating point rounding issues
|
||||
this.canScrollLeft = scrollLeft > 1;
|
||||
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||
}
|
||||
|
||||
@action
|
||||
scrollLeft() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: -this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollRight() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
handlePhotoClick(photo) {
|
||||
if (this.args.onPhotoClick) {
|
||||
this.args.onPhotoClick(photo);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.photos.length}}
|
||||
<div class="photo-carousel {{this.variantClass}}">
|
||||
<div
|
||||
class="photo-carousel-track"
|
||||
{{this.setupCarousel}}
|
||||
{{this.resetScrollPosition @resetKey}}
|
||||
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||
{{on "scroll" this.updateScrollState}}
|
||||
>
|
||||
{{#each this.photos as |photo|}}
|
||||
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
|
||||
<div
|
||||
class="carousel-slide
|
||||
{{if photo.isLandscape 'landscape' 'portrait'}}
|
||||
{{if
|
||||
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
|
||||
'active'
|
||||
}}"
|
||||
style={{photo.style}}
|
||||
data-event-id={{photo.eventId}}
|
||||
{{on "click" (fn this.handlePhotoClick photo)}}
|
||||
>
|
||||
{{#if photo.blurhash}}
|
||||
<Blurhash
|
||||
@hash={{photo.blurhash}}
|
||||
@width={{32}}
|
||||
@height={{18}}
|
||||
class="place-header-photo-blur"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="carousel-placeholder"></div>
|
||||
</div>
|
||||
|
||||
{{#if this.showChevrons}}
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn prev
|
||||
{{if this.cannotScrollLeft 'disabled'}}"
|
||||
{{on "click" this.scrollLeft}}
|
||||
disabled={{this.cannotScrollLeft}}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<Icon
|
||||
@name="chevron-left"
|
||||
@color="currentColor"
|
||||
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn next
|
||||
{{if this.cannotScrollRight 'disabled'}}"
|
||||
{{on "click" this.scrollRight}}
|
||||
disabled={{this.cannotScrollRight}}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<Icon
|
||||
@name="chevron-right"
|
||||
@color="currentColor"
|
||||
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||
/>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
85
app/components/photo-gallery.gjs
Normal file
85
app/components/photo-gallery.gjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
|
||||
export default class PhotoGallery extends Component {
|
||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||
|
||||
@action
|
||||
handleClose() {
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBackgroundClick(e) {
|
||||
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
|
||||
if (
|
||||
e.target.closest('.thumbnail-strip-container') ||
|
||||
e.target.closest('.carousel-nav-btn') ||
|
||||
e.target.closest('.close-btn')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
}
|
||||
|
||||
@action
|
||||
selectPhoto(photo) {
|
||||
this.currentPhoto = photo;
|
||||
}
|
||||
|
||||
@action
|
||||
handleVisiblePhotoChange(photo) {
|
||||
if (this.currentPhoto !== photo) {
|
||||
this.currentPhoto = photo;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.handleBackgroundClick}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="photo-gallery-content">
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn btn-text"
|
||||
{{on "click" this.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={{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>
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import { humanizeOsmTag } from '../utils/format-text';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||
import { getSocialInfo } from '../utils/social-links';
|
||||
import { parsePlacePhotos } from '../utils/nostr';
|
||||
import Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
import PlaceListsManager from './place-lists-manager';
|
||||
import PlacePhotoUpload from './place-photo-upload';
|
||||
import NostrConnect from './nostr-connect';
|
||||
import Modal from './modal';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import PhotoGallery from './photo-gallery';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -19,10 +22,21 @@ import { action } from '@ember/object';
|
||||
export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
@tracked isConnectingNostr = false;
|
||||
@tracked isGalleryOpen = false;
|
||||
@tracked selectedGalleryPhoto = null;
|
||||
@tracked isPhotoUploadModalOpen = false;
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
@tracked newlyUploadedPhotoId = null;
|
||||
|
||||
@action
|
||||
handleUploadStateChange(isActive) {
|
||||
this.isPhotoUploadActive = isActive;
|
||||
}
|
||||
|
||||
@action
|
||||
openPhotoUploadModal(e) {
|
||||
@@ -37,8 +51,20 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
closePhotoUploadModal() {
|
||||
closePhotoUploadModal(eventId) {
|
||||
if (this.isPhotoUploadActive) return;
|
||||
this.isPhotoUploadModalOpen = false;
|
||||
if (typeof eventId === 'string') {
|
||||
this.newlyUploadedPhotoId = eventId;
|
||||
|
||||
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
|
||||
setTimeout(() => {
|
||||
const sidebar = document.querySelector('.sidebar-content');
|
||||
if (sidebar) {
|
||||
sidebar.scrollTop = 0;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -76,6 +102,16 @@ export default class PlaceDetails extends Component {
|
||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
||||
}
|
||||
|
||||
get photos() {
|
||||
const rawPhotos = this.nostrData.placePhotos;
|
||||
const parsedPhotos = parsePlacePhotos(rawPhotos);
|
||||
|
||||
return parsedPhotos.map((photo) => ({
|
||||
...photo,
|
||||
style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`),
|
||||
}));
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
@@ -330,6 +366,18 @@ export default class PlaceDetails extends Component {
|
||||
return !!this.place.description;
|
||||
}
|
||||
|
||||
@action
|
||||
openGallery(photo) {
|
||||
this.selectedGalleryPhoto = photo;
|
||||
this.isGalleryOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeGallery() {
|
||||
this.isGalleryOpen = false;
|
||||
this.selectedGalleryPhoto = null;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
@@ -339,6 +387,14 @@ export default class PlaceDetails extends Component {
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<PhotoCarousel
|
||||
@variant="inline"
|
||||
@photos={{this.photos}}
|
||||
@name={{this.name}}
|
||||
@resetKey={{this.place.osmId}}
|
||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||
@onPhotoClick={{this.openGallery}}
|
||||
/>
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
@@ -538,11 +594,15 @@ export default class PlaceDetails extends Component {
|
||||
{{#if this.osmUrl}}
|
||||
<div class="meta-info">
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="camera" />
|
||||
<Icon @name="feather-camera" />
|
||||
<span>
|
||||
<a href="#" {{on "click" this.openPhotoUploadModal}}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
{{on "click" this.openPhotoUploadModal}}
|
||||
>
|
||||
Add a photo
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -550,8 +610,15 @@ export default class PlaceDetails extends Component {
|
||||
</div>
|
||||
|
||||
{{#if this.isPhotoUploadModalOpen}}
|
||||
<Modal @onClose={{this.closePhotoUploadModal}}>
|
||||
<PlacePhotoUpload @place={{this.saveablePlace}} />
|
||||
<Modal
|
||||
@onClose={{this.closePhotoUploadModal}}
|
||||
@disableClose={{this.isPhotoUploadActive}}
|
||||
>
|
||||
<PlacePhotoUpload
|
||||
@place={{this.saveablePlace}}
|
||||
@onClose={{this.closePhotoUploadModal}}
|
||||
@onUploadStateChange={{this.handleUploadStateChange}}
|
||||
/>
|
||||
</Modal>
|
||||
{{/if}}
|
||||
|
||||
@@ -560,5 +627,14 @@ export default class PlaceDetails extends Component {
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isGalleryOpen}}
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedGalleryPhoto}}
|
||||
@placeName={{this.name}}
|
||||
@onClose={{this.closeGallery}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { isMobile } from '../utils/device';
|
||||
import Blurhash from './blurhash';
|
||||
|
||||
const MAX_IMAGE_DIMENSION = 1920;
|
||||
const IMAGE_QUALITY = 0.94;
|
||||
const IMAGE_QUALITY = 0.9;
|
||||
const MAX_THUMBNAIL_DIMENSION = 350;
|
||||
const THUMBNAIL_QUALITY = 0.9;
|
||||
|
||||
@@ -19,7 +20,9 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
@service toast;
|
||||
|
||||
@tracked thumbnailUrl = '';
|
||||
@tracked blurhash = '';
|
||||
@tracked error = '';
|
||||
@tracked statusText = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
@@ -45,6 +48,7 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
|
||||
uploadTask = task(async (file) => {
|
||||
this.error = '';
|
||||
this.statusText = 'Processing';
|
||||
try {
|
||||
// 1. Process main image and generate blurhash in worker
|
||||
const mainData = await this.imageProcessor.process(
|
||||
@@ -54,6 +58,8 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
true // computeBlurhash
|
||||
);
|
||||
|
||||
this.blurhash = mainData.blurhash;
|
||||
|
||||
// 2. Process thumbnail (no blurhash needed)
|
||||
const thumbData = await this.imageProcessor.process(
|
||||
file,
|
||||
@@ -67,18 +73,34 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
let mainResult, thumbResult;
|
||||
const isMobileDevice = isMobile();
|
||||
|
||||
const mainProgress = (status) => {
|
||||
if (status === 'signing') this.statusText = 'Signing photo upload';
|
||||
if (status === 'uploading') this.statusText = 'Uploading photo';
|
||||
};
|
||||
|
||||
const thumbProgress = (status) => {
|
||||
if (status === 'signing') this.statusText = 'Signing thumbnail upload';
|
||||
if (status === 'uploading') this.statusText = 'Uploading thumbnail';
|
||||
};
|
||||
|
||||
if (isMobileDevice) {
|
||||
// Mobile: sequential uploads to preserve bandwidth and memory
|
||||
mainResult = await this.blossom.upload(mainData.blob, {
|
||||
sequential: true,
|
||||
onProgress: mainProgress,
|
||||
});
|
||||
thumbResult = await this.blossom.upload(thumbData.blob, {
|
||||
sequential: true,
|
||||
onProgress: thumbProgress,
|
||||
});
|
||||
} else {
|
||||
// Desktop: concurrent uploads
|
||||
const mainUploadPromise = this.blossom.upload(mainData.blob);
|
||||
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
|
||||
const mainUploadPromise = this.blossom.upload(mainData.blob, {
|
||||
onProgress: mainProgress,
|
||||
});
|
||||
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
|
||||
onProgress: thumbProgress,
|
||||
});
|
||||
|
||||
[mainResult, thumbResult] = await Promise.all([
|
||||
mainUploadPromise,
|
||||
@@ -110,6 +132,9 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
{{if this.uploadTask.isRunning 'is-uploading'}}
|
||||
{{if this.error 'has-error'}}"
|
||||
>
|
||||
{{#if this.blurhash}}
|
||||
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
|
||||
{{/if}}
|
||||
<img src={{this.thumbnailUrl}} alt="thumbnail" />
|
||||
|
||||
{{#if this.uploadTask.isRunning}}
|
||||
@@ -120,6 +145,9 @@ export default class PlacePhotoUploadItem extends Component {
|
||||
@color="white"
|
||||
class="spin-animation"
|
||||
/>
|
||||
{{#if this.statusText}}
|
||||
<span class="upload-status-text">{{this.statusText}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import { or, not } from 'ember-truth-helpers';
|
||||
export default class PlacePhotoUpload extends Component {
|
||||
@service nostrAuth;
|
||||
@service nostrRelay;
|
||||
@service nostrData;
|
||||
@service blossom;
|
||||
@service toast;
|
||||
|
||||
@tracked files = [];
|
||||
@tracked uploadedPhotos = [];
|
||||
@tracked status = '';
|
||||
@tracked file = null;
|
||||
@tracked uploadedPhoto = null;
|
||||
@tracked error = '';
|
||||
@tracked isPublishing = false;
|
||||
@tracked isDragging = false;
|
||||
@@ -33,17 +33,13 @@ export default class PlacePhotoUpload extends Component {
|
||||
|
||||
get allUploaded() {
|
||||
return (
|
||||
this.files.length > 0 && this.files.length === this.uploadedPhotos.length
|
||||
this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
|
||||
);
|
||||
}
|
||||
|
||||
get photoWord() {
|
||||
return this.files.length === 1 ? 'Photo' : 'Photos';
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
this.addFiles(event.target.files);
|
||||
this.addFile(event.target.files[0]);
|
||||
event.target.value = ''; // Reset input
|
||||
}
|
||||
|
||||
@@ -63,32 +59,42 @@ export default class PlacePhotoUpload extends Component {
|
||||
handleDrop(event) {
|
||||
event.preventDefault();
|
||||
this.isDragging = false;
|
||||
this.addFiles(event.dataTransfer.files);
|
||||
if (event.dataTransfer.files.length > 0) {
|
||||
this.addFile(event.dataTransfer.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
addFiles(fileList) {
|
||||
if (!fileList) return;
|
||||
const newFiles = Array.from(fileList).filter((f) =>
|
||||
f.type.startsWith('image/')
|
||||
);
|
||||
this.files = [...this.files, ...newFiles];
|
||||
addFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
this.error = 'Please select a valid image file.';
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
// If a photo was already uploaded but not published, delete it from the server
|
||||
if (this.uploadedPhoto) {
|
||||
this.deletePhotoTask.perform(this.uploadedPhoto);
|
||||
}
|
||||
this.file = file;
|
||||
this.uploadedPhoto = null;
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(true);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleUploadSuccess(photoData) {
|
||||
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
|
||||
this.uploadedPhoto = photoData;
|
||||
}
|
||||
|
||||
@action
|
||||
removeFile(fileToRemove) {
|
||||
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
|
||||
this.files = this.files.filter((f) => f !== fileToRemove);
|
||||
this.uploadedPhotos = this.uploadedPhotos.filter(
|
||||
(p) => p.file !== fileToRemove
|
||||
);
|
||||
|
||||
if (photoData && photoData.hash && photoData.url) {
|
||||
this.deletePhotoTask.perform(photoData);
|
||||
removeFile() {
|
||||
if (this.uploadedPhoto) {
|
||||
this.deletePhotoTask.perform(this.uploadedPhoto);
|
||||
}
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +131,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'Publishing event...';
|
||||
this.error = '';
|
||||
this.isPublishing = true;
|
||||
|
||||
@@ -141,34 +146,33 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||
}
|
||||
|
||||
for (const photo of this.uploadedPhotos) {
|
||||
const imeta = ['imeta', `url ${photo.url}`];
|
||||
const photo = this.uploadedPhoto;
|
||||
const imeta = ['imeta', `url ${photo.url}`];
|
||||
|
||||
imeta.push(`m ${photo.type}`);
|
||||
imeta.push(`m ${photo.type}`);
|
||||
|
||||
if (photo.dim) {
|
||||
imeta.push(`dim ${photo.dim}`);
|
||||
}
|
||||
|
||||
imeta.push('alt A photo of a place');
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
imeta.push(`fallback ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (photo.thumbUrl) {
|
||||
imeta.push(`thumb ${photo.thumbUrl}`);
|
||||
}
|
||||
|
||||
if (photo.blurhash) {
|
||||
imeta.push(`blurhash ${photo.blurhash}`);
|
||||
}
|
||||
|
||||
tags.push(imeta);
|
||||
if (photo.dim) {
|
||||
imeta.push(`dim ${photo.dim}`);
|
||||
}
|
||||
|
||||
imeta.push('alt A photo of a place');
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
imeta.push(`fallback ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (photo.thumbUrl) {
|
||||
imeta.push(`thumb ${photo.thumbUrl}`);
|
||||
}
|
||||
|
||||
if (photo.blurhash) {
|
||||
imeta.push(`blurhash ${photo.blurhash}`);
|
||||
}
|
||||
|
||||
tags.push(imeta);
|
||||
|
||||
// NIP-XX draft Place Photo event
|
||||
const template = {
|
||||
kind: 360,
|
||||
@@ -181,16 +185,24 @@ export default class PlacePhotoUpload extends Component {
|
||||
}
|
||||
|
||||
const event = await factory.sign(template);
|
||||
await this.nostrRelay.publish(event);
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
this.status = 'Published successfully!';
|
||||
this.toast.show('Photo published successfully');
|
||||
|
||||
// Clear out the files so user can upload more or be done
|
||||
this.files = [];
|
||||
this.uploadedPhotos = [];
|
||||
// Clear out the file so user can upload more or be done
|
||||
this.file = null;
|
||||
this.uploadedPhoto = null;
|
||||
|
||||
if (this.args.onUploadStateChange) {
|
||||
this.args.onUploadStateChange(false);
|
||||
}
|
||||
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose(event.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Failed to publish: ' + e.message;
|
||||
this.status = '';
|
||||
} finally {
|
||||
this.isPublishing = false;
|
||||
}
|
||||
@@ -198,7 +210,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
|
||||
<template>
|
||||
<div class="place-photo-upload">
|
||||
<h2>Add Photos for {{this.title}}</h2>
|
||||
<h2>Add Photo for {{this.title}}</h2>
|
||||
|
||||
{{#if this.error}}
|
||||
<div class="alert alert-error">
|
||||
@@ -206,42 +218,13 @@ export default class PlacePhotoUpload extends Component {
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.status}}
|
||||
<div class="alert alert-info">
|
||||
{{this.status}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
class="dropzone {{if this.isDragging 'is-dragging'}}"
|
||||
{{on "dragover" this.handleDragOver}}
|
||||
{{on "dragleave" this.handleDragLeave}}
|
||||
{{on "drop" this.handleDrop}}
|
||||
>
|
||||
<label for="photo-upload-input" class="dropzone-label">
|
||||
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
|
||||
<p>Drag and drop photos here, or click to browse</p>
|
||||
</label>
|
||||
<input
|
||||
id="photo-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="file-input-hidden"
|
||||
disabled={{this.isPublishing}}
|
||||
{{on "change" this.handleFileSelect}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.files.length}}
|
||||
{{#if this.file}}
|
||||
<div class="photo-grid">
|
||||
{{#each this.files as |file|}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
{{/each}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -253,11 +236,29 @@ export default class PlacePhotoUpload extends Component {
|
||||
{{#if this.isPublishing}}
|
||||
Publishing...
|
||||
{{else}}
|
||||
Publish
|
||||
{{this.files.length}}
|
||||
{{this.photoWord}}
|
||||
Publish Photo
|
||||
{{/if}}
|
||||
</button>
|
||||
{{else}}
|
||||
<div
|
||||
class="dropzone {{if this.isDragging 'is-dragging'}}"
|
||||
{{on "dragover" this.handleDragOver}}
|
||||
{{on "dragleave" this.handleDragLeave}}
|
||||
{{on "drop" this.handleDrop}}
|
||||
>
|
||||
<label for="photo-upload-input" class="dropzone-label">
|
||||
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
|
||||
<p>Drag and drop a photo here, or click to browse</p>
|
||||
</label>
|
||||
<input
|
||||
id="photo-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="file-input-hidden"
|
||||
disabled={{this.isPublishing}}
|
||||
{{on "change" this.handleFileSelect}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service nostrData;
|
||||
|
||||
@action
|
||||
createNewPlace() {
|
||||
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
}
|
||||
|
||||
get hasHeaderPhoto() {
|
||||
return (
|
||||
this.args.selectedPlace &&
|
||||
this.nostrData.placePhotos &&
|
||||
this.nostrData.placePhotos.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||
{{#if @selectedPlace}}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
@service osm;
|
||||
@service photon;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service toast;
|
||||
|
||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||
|
||||
lat = null;
|
||||
@@ -8,4 +18,175 @@ export default class SearchController extends Controller {
|
||||
q = null;
|
||||
selected = null;
|
||||
category = null;
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// Hide sidebar and clear previous results immediately to signal a new search
|
||||
this.mapUi.hideSidebar();
|
||||
this.mapUi.clearSearchResults();
|
||||
|
||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
let loadingType = null;
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// We need bounds. If we have active map state, use it.
|
||||
let bounds = this.mapUi.currentBounds;
|
||||
|
||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||
// or just use a fixed box around the center.
|
||||
if (!bounds) {
|
||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||
// Let's take a safe box of ~1km radius.
|
||||
const delta = 0.01;
|
||||
bounds = {
|
||||
minLat: lat - delta,
|
||||
maxLat: lat + delta,
|
||||
minLon: lon - delta,
|
||||
maxLon: lon + delta,
|
||||
};
|
||||
}
|
||||
|
||||
pois = await this.osm.getCategoryPois(
|
||||
bounds,
|
||||
params.category,
|
||||
lat,
|
||||
lon
|
||||
);
|
||||
|
||||
// Sort by distance from center
|
||||
pois = pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name
|
||||
const queryLower = params.q.toLowerCase();
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search request failed.', error);
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
this.mapUi.stopSearch();
|
||||
return;
|
||||
} finally {
|
||||
if (loadingType && loadingValue) {
|
||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of these are already bookmarked
|
||||
// We resolve them to the bookmark version if they exist
|
||||
pois = pois.map((p) => {
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = pois.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||
// Note: MapComponent had logic for <=20m + type match.
|
||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||
// For now, let's stick to name or very close proximity.
|
||||
if (!matchedPlace) {
|
||||
const topCandidate = pois[0];
|
||||
if (topCandidate._distance <= 10) {
|
||||
matchedPlace = topCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPlace) {
|
||||
// Direct transition!
|
||||
this.router.replaceWith('place', matchedPlace);
|
||||
this.mapUi.stopSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.mapUi.setSearchResults(pois);
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.stopSearch();
|
||||
});
|
||||
}
|
||||
|
||||
64
app/modifiers/cached-image.js
Normal file
64
app/modifiers/cached-image.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
const CACHE_NAME = 'nostr-image-cache-v1';
|
||||
|
||||
export default modifier((element, [url]) => {
|
||||
let objectUrl = null;
|
||||
|
||||
async function loadImage() {
|
||||
if (!url) {
|
||||
element.src = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedResponse = await cache.match(url);
|
||||
|
||||
if (cachedResponse) {
|
||||
const blob = await cachedResponse.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
element.src = objectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache, try to fetch it
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors', // Required to read the blob for caching
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Clone the response before reading the blob because a response stream can only be read once
|
||||
const cacheResponse = response.clone();
|
||||
await cache.put(url, cacheResponse);
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
element.src = objectUrl;
|
||||
} else {
|
||||
// Fetch failed (e.g. 404), fallback to standard browser loading
|
||||
element.src = url;
|
||||
}
|
||||
} catch (error) {
|
||||
// CORS errors or network failures will land here.
|
||||
// Fallback to letting the browser handle it directly.
|
||||
console.warn(
|
||||
`Failed to cache image ${url}, falling back to standard src`,
|
||||
error
|
||||
);
|
||||
element.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
loadImage();
|
||||
|
||||
// Cleanup: revoke the object URL when the element is destroyed or the URL changes
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
93
app/modifiers/fade-in-image.js
Normal file
93
app/modifiers/fade-in-image.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [url]) => {
|
||||
if (!url) return;
|
||||
|
||||
// Remove classes when URL changes
|
||||
element.classList.remove('loaded');
|
||||
element.classList.remove('loaded-instant');
|
||||
|
||||
let observer;
|
||||
|
||||
const hideBlurhash = () => {
|
||||
const parent = element.parentElement;
|
||||
const slide =
|
||||
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
|
||||
|
||||
// Only hide the blurhash if we're in the gallery-main view.
|
||||
// In the inline view, we want to keep the blurhash visible behind portrait photos
|
||||
// to fill the 16:9 container gracefully.
|
||||
if (slide && slide.closest('.photo-carousel.gallery-main')) {
|
||||
const blur = slide.querySelector('.place-header-photo-blur');
|
||||
if (blur) {
|
||||
blur.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||
if (!element.classList.contains('loaded-instant')) {
|
||||
element.classList.add('loaded');
|
||||
}
|
||||
hideBlurhash();
|
||||
};
|
||||
|
||||
element.addEventListener('load', handleLoad);
|
||||
|
||||
const loadWhenVisible = (entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Stop observing once we start loading
|
||||
obs.unobserve(element);
|
||||
|
||||
// Check if the image is already in the browser cache
|
||||
// Create an off-DOM image to reliably check cache status
|
||||
// without waiting for the actual DOM element to load it
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
|
||||
if (img.complete) {
|
||||
// Already in browser cache, skip the animation
|
||||
element.classList.add('loaded-instant');
|
||||
hideBlurhash();
|
||||
}
|
||||
|
||||
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||
const parent = element.parentElement;
|
||||
if (parent && parent.tagName === 'PICTURE') {
|
||||
const sources = parent.querySelectorAll('source');
|
||||
sources.forEach((source) => {
|
||||
if (source.dataset.srcset) {
|
||||
source.srcset = source.dataset.srcset;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Swap data-src to src to trigger the actual network fetch (or render from cache)
|
||||
if (element.dataset.src) {
|
||||
element.src = element.dataset.src;
|
||||
} else {
|
||||
// Fallback if data-src wasn't used but the modifier was called
|
||||
element.src = url;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Setup Intersection Observer to only load when the image enters the viewport
|
||||
observer = new IntersectionObserver(loadWhenVisible, {
|
||||
root: null, // Use the viewport as the root
|
||||
rootMargin: '100px 100%', // Load one full viewport width ahead/behind
|
||||
threshold: 0, // Trigger immediately when any part enters the expanded margin
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('load', handleLoad);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
|
||||
async model(params) {
|
||||
const id = params.place_id;
|
||||
|
||||
let type, osmId;
|
||||
let isExplicitOsm = false;
|
||||
|
||||
if (
|
||||
id.startsWith('osm:node:') ||
|
||||
id.startsWith('osm:way:') ||
|
||||
id.startsWith('osm:relation:')
|
||||
) {
|
||||
const [, type, osmId] = id.split(':');
|
||||
isExplicitOsm = true;
|
||||
[, type, osmId] = id.split(':');
|
||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||
return this.loadOsmPlace(osmId, type);
|
||||
}
|
||||
|
||||
let backgroundFetchPromise = null;
|
||||
if (isExplicitOsm) {
|
||||
backgroundFetchPromise = this.loadOsmPlace(osmId, type);
|
||||
}
|
||||
|
||||
await this.waitForSync();
|
||||
|
||||
let bookmark = this.storage.findPlaceById(id);
|
||||
let lookupId = isExplicitOsm ? osmId : id;
|
||||
let bookmark = this.storage.findPlaceById(lookupId);
|
||||
|
||||
// Ensure type matches if we are looking up by osmId
|
||||
if (bookmark && isExplicitOsm && bookmark.osmType !== type) {
|
||||
bookmark = null; // Type mismatch, not the same OSM object
|
||||
}
|
||||
|
||||
if (bookmark) {
|
||||
console.debug('Found in bookmarks:', bookmark.title);
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
if (isExplicitOsm) {
|
||||
console.debug(
|
||||
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
|
||||
osmId
|
||||
);
|
||||
return await backgroundFetchPromise;
|
||||
}
|
||||
|
||||
console.warn('Not in bookmarks:', id);
|
||||
return null;
|
||||
}
|
||||
@@ -74,6 +96,7 @@ export default class PlaceRoute extends Route {
|
||||
if (model) {
|
||||
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||
this.mapUi.selectPlace(model, options);
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = false;
|
||||
}
|
||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||
@@ -119,14 +142,14 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
serialize(model) {
|
||||
// If the model is a saved bookmark, use its ID
|
||||
if (model.id) {
|
||||
return { place_id: model.id };
|
||||
}
|
||||
// If it's an OSM POI, use the explicit format
|
||||
// If it's an OSM POI, use the explicit format first
|
||||
if (model.osmId && model.osmType) {
|
||||
return { place_id: `osm:${model.osmType}:${model.osmId}` };
|
||||
}
|
||||
// If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID
|
||||
if (model.id) {
|
||||
return { place_id: model.id };
|
||||
}
|
||||
// Fallback
|
||||
return { place_id: model.osmId };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
|
||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||
}
|
||||
this.mapUi.startCreating();
|
||||
this.mapUi.showSidebar();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class SearchRoute extends Route {
|
||||
@service osm;
|
||||
@service photon;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service toast;
|
||||
|
||||
queryParams = {
|
||||
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
|
||||
category: { refreshModel: true },
|
||||
};
|
||||
|
||||
async model(params) {
|
||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
let loadingType = null;
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// We need bounds. If we have active map state, use it.
|
||||
let bounds = this.mapUi.currentBounds;
|
||||
|
||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||
// or just use a fixed box around the center.
|
||||
if (!bounds) {
|
||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||
// Let's take a safe box of ~1km radius.
|
||||
const delta = 0.01;
|
||||
bounds = {
|
||||
minLat: lat - delta,
|
||||
maxLat: lat + delta,
|
||||
minLon: lon - delta,
|
||||
maxLon: lon + delta,
|
||||
};
|
||||
}
|
||||
|
||||
pois = await this.osm.getCategoryPois(
|
||||
bounds,
|
||||
params.category,
|
||||
lat,
|
||||
lon
|
||||
);
|
||||
|
||||
// Sort by distance from center
|
||||
pois = pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name
|
||||
const queryLower = params.q.toLowerCase();
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} finally {
|
||||
if (loadingType && loadingValue) {
|
||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of these are already bookmarked
|
||||
// We resolve them to the bookmark version if they exist
|
||||
pois = pois.map((p) => {
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
return pois;
|
||||
}
|
||||
|
||||
afterModel(model, transition) {
|
||||
const { q, selected } = transition.to.queryParams;
|
||||
|
||||
// Heuristic Match Logic (ported from MapComponent)
|
||||
// If 'selected' is provided (from map click), try to find that specific feature.
|
||||
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
||||
const targetName = selected || q;
|
||||
|
||||
if (targetName && model.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = model.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||
// Note: MapComponent had logic for <=20m + type match.
|
||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||
// For now, let's stick to name or very close proximity.
|
||||
if (!matchedPlace) {
|
||||
const topCandidate = model[0];
|
||||
if (topCandidate._distance <= 10) {
|
||||
matchedPlace = topCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPlace) {
|
||||
// Direct transition!
|
||||
this.router.replaceWith('place', matchedPlace);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the pulse animation since search is done (and we are staying here)
|
||||
this.mapUi.stopSearch();
|
||||
model(params) {
|
||||
// Just return params, doing the async fetch in the controller
|
||||
return params;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
// Ensure pulse is stopped if we reach here
|
||||
this.mapUi.stopSearch();
|
||||
this.mapUi.setSearchResults(model);
|
||||
|
||||
// Trigger the background task to fetch results
|
||||
controller.fetchResultsTask.perform(model);
|
||||
|
||||
// Store current search params to allow "Up" navigation from place details
|
||||
const { q, category, lat, lon } = this.paramsFor('search');
|
||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.fetchResultsTask.cancelAll();
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
error(error, transition) {
|
||||
this.mapUi.stopSearch();
|
||||
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
|
||||
if (transition) {
|
||||
transition.abort();
|
||||
}
|
||||
return false; // Prevent bubble and stop transition
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,17 @@ function getBlossomUrl(serverUrl, path) {
|
||||
export default class BlossomService extends Service {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service settings;
|
||||
|
||||
get servers() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
|
||||
if (!this.settings.nostrPhotoFallbackUploads) {
|
||||
return [allServers[0]];
|
||||
}
|
||||
|
||||
return allServers;
|
||||
}
|
||||
|
||||
async _getAuthHeader(action, hash, serverUrl) {
|
||||
@@ -53,10 +60,13 @@ export default class BlossomService extends Service {
|
||||
return `Nostr ${base64url}`;
|
||||
}
|
||||
|
||||
async _uploadToServer(file, hash, serverUrl) {
|
||||
async _uploadToServer(file, hash, serverUrl, onProgress) {
|
||||
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
||||
|
||||
if (onProgress) onProgress('signing');
|
||||
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
||||
|
||||
if (onProgress) onProgress('uploading');
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
@@ -102,14 +112,20 @@ export default class BlossomService extends Service {
|
||||
|
||||
if (options.sequential) {
|
||||
// Sequential upload logic
|
||||
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
|
||||
mainResult = await this._uploadToServer(
|
||||
file,
|
||||
payloadHash,
|
||||
mainServer,
|
||||
options.onProgress
|
||||
);
|
||||
|
||||
for (const serverUrl of fallbackServers) {
|
||||
try {
|
||||
const result = await this._uploadToServer(
|
||||
file,
|
||||
payloadHash,
|
||||
serverUrl
|
||||
serverUrl,
|
||||
options.onProgress
|
||||
);
|
||||
fallbackUrls.push(result.url);
|
||||
} catch (error) {
|
||||
@@ -118,9 +134,14 @@ export default class BlossomService extends Service {
|
||||
}
|
||||
} else {
|
||||
// Concurrent upload logic
|
||||
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
|
||||
const mainPromise = this._uploadToServer(
|
||||
file,
|
||||
payloadHash,
|
||||
mainServer,
|
||||
options.onProgress
|
||||
);
|
||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||
this._uploadToServer(file, payloadHash, serverUrl)
|
||||
this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
|
||||
);
|
||||
|
||||
// Main server MUST succeed
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Service from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@service nostrData;
|
||||
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSearching = false;
|
||||
@tracked isCreating = false;
|
||||
@@ -9,22 +11,34 @@ export default class MapUiService extends Service {
|
||||
@tracked returnToSearch = false;
|
||||
@tracked currentCenter = null;
|
||||
@tracked currentBounds = null;
|
||||
@tracked currentZoom = null;
|
||||
@tracked searchBoxHasFocus = false;
|
||||
@tracked selectionOptions = {};
|
||||
@tracked preventNextZoom = false;
|
||||
@tracked searchResults = [];
|
||||
@tracked currentSearch = null;
|
||||
@tracked loadingState = null;
|
||||
@tracked isSidebarVisible = false;
|
||||
|
||||
showSidebar() {
|
||||
this.isSidebarVisible = true;
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
this.isSidebarVisible = false;
|
||||
}
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
this.selectedPlace = place;
|
||||
this.selectionOptions = options;
|
||||
this.nostrData.loadPhotosForPlace(place);
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedPlace = null;
|
||||
this.selectionOptions = {};
|
||||
this.preventNextZoom = false;
|
||||
this.nostrData.loadPhotosForPlace(null);
|
||||
}
|
||||
|
||||
setSearchResults(results) {
|
||||
@@ -68,6 +82,10 @@ export default class MapUiService extends Service {
|
||||
this.currentCenter = { lat, lon };
|
||||
}
|
||||
|
||||
updateZoom(zoom) {
|
||||
this.currentZoom = zoom;
|
||||
}
|
||||
|
||||
updateBounds(bounds) {
|
||||
this.currentBounds = bounds;
|
||||
}
|
||||
|
||||
@@ -4,32 +4,77 @@ import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import { normalizeRelayUrl } from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
|
||||
const BOOTSTRAP_RELAYS = [
|
||||
const DIRECTORY_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
];
|
||||
|
||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||
const DEFAULT_WRITE_RELAYS = [];
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
@service settings;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
loadedGeohashPrefixes = new Set();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Initialize the IndexedDB cache
|
||||
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||
this.cache = new NostrIDB(db, {
|
||||
cacheIndexes: 1000,
|
||||
maxEvents: 10000,
|
||||
});
|
||||
|
||||
await this.cache.start();
|
||||
|
||||
// Automatically persist new events to the cache
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
e.kind === 360
|
||||
);
|
||||
|
||||
if (toCache.length > 0) {
|
||||
await Promise.all(toCache.map((event) => this.cache.add(event)));
|
||||
}
|
||||
},
|
||||
{
|
||||
batchTime: 1000, // Batch writes every 1 second
|
||||
maxBatchSize: 100,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||
@@ -37,6 +82,177 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
get defaultReadRelays() {
|
||||
const mailboxes = (this.mailboxes?.inboxes || [])
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean);
|
||||
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||
}
|
||||
|
||||
get defaultWriteRelays() {
|
||||
const mailboxes = (this.mailboxes?.outboxes || [])
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean);
|
||||
const defaults =
|
||||
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
if (this.settings.nostrReadRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultReadRelays;
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
if (this.settings.nostrWriteRelays) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.defaultWriteRelays;
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
const missingPrefixes = requiredPrefixes.filter(
|
||||
(p) => !this.loadedGeohashPrefixes.has(p)
|
||||
);
|
||||
|
||||
if (missingPrefixes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
'[nostr-data] Loading place photos for prefixes:',
|
||||
missingPrefixes
|
||||
);
|
||||
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
kinds: [360],
|
||||
'#g': missingPrefixes,
|
||||
},
|
||||
]);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read photos from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request for new prefixes
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
'#g': missingPrefixes,
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos by geohash:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
for (const p of missingPrefixes) {
|
||||
this.loadedGeohashPrefixes.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPhotosForPlace(place) {
|
||||
if (this._photosSub) {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
|
||||
this.placePhotos = [];
|
||||
|
||||
if (!place || !place.osmId || !place.osmType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
||||
|
||||
// Setup reactive store query
|
||||
this._photosSub = this.store
|
||||
.timeline([
|
||||
{
|
||||
kinds: [360],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
});
|
||||
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
kinds: [360],
|
||||
'#i': [entityId],
|
||||
},
|
||||
]);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read photos for place from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request specifically for this place
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos for place:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -47,40 +263,8 @@ export default class NostrDataService extends Service {
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
const relays = new Set(BOOTSTRAP_RELAYS);
|
||||
|
||||
// Try to get extension relays
|
||||
if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
|
||||
try {
|
||||
const extRelays = await window.nostr.getRelays();
|
||||
for (const url of Object.keys(extRelays)) {
|
||||
relays.add(url);
|
||||
}
|
||||
} catch {
|
||||
console.warn('Failed to get NIP-07 relays');
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = Array.from(relays);
|
||||
|
||||
// Request events and dump them into the store
|
||||
this._requestSub = this.nostrRelay.pool
|
||||
.request(relayList, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching profile events:', err);
|
||||
},
|
||||
});
|
||||
|
||||
// Setup models to track state reactively
|
||||
// Setup models to track state reactively FIRST
|
||||
// This way, if cached events populate the store, the UI updates instantly.
|
||||
this._profileSub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
.subscribe((profileContent) => {
|
||||
@@ -104,6 +288,46 @@ export default class NostrDataService extends Service {
|
||||
this.blossomServers = [];
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Await cache initialization and populate the EventStore with local data
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
]);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to read from local Nostr IDB cache', e);
|
||||
}
|
||||
|
||||
// 2. Request new events from the network in the background and dump them into the store
|
||||
const profileRelays = Array.from(
|
||||
new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
|
||||
);
|
||||
this._requestSub = this.nostrRelay.pool
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching profile events:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get userDisplayName() {
|
||||
@@ -132,6 +356,13 @@ export default class NostrDataService extends Service {
|
||||
return 'Not connected';
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
await this._cachePromise;
|
||||
if (this.cache) {
|
||||
await this.cache.deleteAllEvents();
|
||||
}
|
||||
}
|
||||
|
||||
_cleanupSubscriptions() {
|
||||
if (this._requestSub) {
|
||||
this._requestSub.unsubscribe();
|
||||
@@ -149,10 +380,22 @@ export default class NostrDataService extends Service {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
}
|
||||
if (this._photosSub) {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
}
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import Service from '@ember/service';
|
||||
import { RelayPool } from 'applesauce-relay';
|
||||
|
||||
export default class NostrRelayService extends Service {
|
||||
pool = new RelayPool();
|
||||
pool = new RelayPool({ enablePing: true });
|
||||
|
||||
// For Phase 1, we hardcode the local relay
|
||||
relays = ['ws://127.0.0.1:7777'];
|
||||
|
||||
async publish(event) {
|
||||
async publish(relays, event) {
|
||||
if (!relays || relays.length === 0) {
|
||||
throw new Error('No relays provided to publish the event.');
|
||||
}
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(this.relays, event);
|
||||
const responses = await this.pool.publish(relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
|
||||
@@ -8,6 +8,7 @@ export default class OsmService extends Service {
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
cachedPlaces = new Map();
|
||||
|
||||
cancelAll() {
|
||||
if (this.controller) {
|
||||
@@ -232,6 +233,13 @@ out center;
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
const cacheKey = `${osmType}:${osmId}`;
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
let url;
|
||||
if (osmType === 'node') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||
@@ -253,8 +261,25 @@ out center;
|
||||
}
|
||||
throw new Error(`OSM API request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
const normalizedData = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
osmId,
|
||||
osmType
|
||||
);
|
||||
|
||||
this.cachedPlaces.set(cacheKey, {
|
||||
data: normalizedData,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Cleanup cache entry automatically after 10 seconds
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, 10000);
|
||||
|
||||
return normalizedData;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch OSM object:', e);
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
overpassApi: 'https://overpass-api.de/api/interpreter',
|
||||
mapKinetic: true,
|
||||
photonApi: 'https://photon.komoot.io/api/',
|
||||
showQuickSearchButtons: true,
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrReadRelays: null,
|
||||
nostrWriteRelays: null,
|
||||
};
|
||||
|
||||
export default class SettingsService extends Service {
|
||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
@tracked mapKinetic = true;
|
||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||
@tracked showQuickSearchButtons = true;
|
||||
@tracked overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||
@tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic;
|
||||
@tracked photonApi = DEFAULT_SETTINGS.photonApi;
|
||||
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||
@tracked nostrPhotoFallbackUploads =
|
||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||
|
||||
overpassApis = [
|
||||
{
|
||||
@@ -39,49 +53,83 @@ export default class SettingsService extends Service {
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||
if (savedApi) {
|
||||
// Check if saved API is still in the allowed list
|
||||
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
||||
if (isValid) {
|
||||
this.overpassApi = savedApi;
|
||||
} else {
|
||||
// If not valid, revert to default
|
||||
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
||||
let settings = {};
|
||||
const savedSettings = localStorage.getItem('marco:settings');
|
||||
|
||||
if (savedSettings) {
|
||||
try {
|
||||
settings = JSON.parse(savedSettings);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse settings from localStorage', e);
|
||||
}
|
||||
} else {
|
||||
// Migration from old individual keys
|
||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||
if (savedApi) settings.overpassApi = savedApi;
|
||||
|
||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||
if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true';
|
||||
|
||||
const savedShowQuickSearch = localStorage.getItem(
|
||||
'marco:show-quick-search'
|
||||
);
|
||||
if (savedShowQuickSearch !== null) {
|
||||
settings.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||
}
|
||||
|
||||
const savedNostrPhotoFallbackUploads = localStorage.getItem(
|
||||
'marco:nostr-photo-fallback-uploads'
|
||||
);
|
||||
if (savedNostrPhotoFallbackUploads !== null) {
|
||||
settings.nostrPhotoFallbackUploads =
|
||||
savedNostrPhotoFallbackUploads === 'true';
|
||||
}
|
||||
|
||||
const savedPhotonApi = localStorage.getItem('marco:photon-api');
|
||||
if (savedPhotonApi) settings.photonApi = savedPhotonApi;
|
||||
}
|
||||
|
||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||
if (savedKinetic !== null) {
|
||||
this.mapKinetic = savedKinetic === 'true';
|
||||
}
|
||||
// Default is true (initialized in class field)
|
||||
// Merge with defaults
|
||||
const finalSettings = { ...DEFAULT_SETTINGS, ...settings };
|
||||
|
||||
const savedShowQuickSearch = localStorage.getItem(
|
||||
'marco:show-quick-search'
|
||||
// Validate overpass API
|
||||
const isValid = this.overpassApis.some(
|
||||
(api) => api.url === finalSettings.overpassApi
|
||||
);
|
||||
if (savedShowQuickSearch !== null) {
|
||||
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||
if (!isValid) {
|
||||
finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||
}
|
||||
|
||||
// Apply to tracked properties
|
||||
this.overpassApi = finalSettings.overpassApi;
|
||||
this.mapKinetic = finalSettings.mapKinetic;
|
||||
this.photonApi = finalSettings.photonApi;
|
||||
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
|
||||
// Save to ensure migrated settings are stored in the new format
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
updateOverpassApi(url) {
|
||||
this.overpassApi = url;
|
||||
localStorage.setItem('marco:overpass-api', url);
|
||||
saveSettings() {
|
||||
const settings = {
|
||||
overpassApi: this.overpassApi,
|
||||
mapKinetic: this.mapKinetic,
|
||||
photonApi: this.photonApi,
|
||||
showQuickSearchButtons: this.showQuickSearchButtons,
|
||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||
nostrReadRelays: this.nostrReadRelays,
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
}
|
||||
|
||||
updateMapKinetic(enabled) {
|
||||
this.mapKinetic = enabled;
|
||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||
}
|
||||
|
||||
updateShowQuickSearchButtons(enabled) {
|
||||
this.showQuickSearchButtons = enabled;
|
||||
localStorage.setItem('marco:show-quick-search', String(enabled));
|
||||
}
|
||||
|
||||
updatePhotonApi(url) {
|
||||
this.photonApi = url;
|
||||
update(key, value) {
|
||||
if (key in DEFAULT_SETTINGS) {
|
||||
this[key] = value;
|
||||
this.saveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
@service osm;
|
||||
@service toast;
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
|
||||
@tracked connected = false;
|
||||
@tracked userAddress = null;
|
||||
@tracked isWidgetOpen = false;
|
||||
isNewConnection = true;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.checkInitialConnectionState();
|
||||
|
||||
this.rs = new RemoteStorage({
|
||||
modules: [Places],
|
||||
});
|
||||
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
|
||||
if (this.isNewConnection) {
|
||||
this.toast.show('Remote storage connected', 3000);
|
||||
this.isNewConnection = false;
|
||||
}
|
||||
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
|
||||
this.loadedPrefixes = [];
|
||||
this.lists = [];
|
||||
this.initialSyncDone = false;
|
||||
this.isNewConnection = true;
|
||||
});
|
||||
|
||||
this.rs.on('sync-done', () => {
|
||||
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
checkInitialConnectionState() {
|
||||
this.isNewConnection = true;
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
const keys = [
|
||||
'remotestorage:wireclient',
|
||||
'remotestorage:dropbox',
|
||||
'remotestorage:googledrive',
|
||||
];
|
||||
for (const key of keys) {
|
||||
const data = window.localStorage.getItem(key);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed && parsed.token) {
|
||||
this.isNewConnection = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to check localStorage for existing connection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
handlePlaceChange(event) {
|
||||
const { newValue, relativePath } = event;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
--link-color-visited: #6a4fbf;
|
||||
--marker-color-primary: #ea4335;
|
||||
--marker-color-dark: #b31412;
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -213,12 +215,12 @@ body {
|
||||
.dropzone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
margin: 1.5rem 0 1rem;
|
||||
background-color: rgb(255 255 255 / 2%);
|
||||
cursor: pointer;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.dropzone.is-dragging {
|
||||
@@ -230,9 +232,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
color: #898989;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dropzone-label p {
|
||||
@@ -244,25 +249,35 @@ body {
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-upload-item img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.photo-upload-item .overlay,
|
||||
.photo-upload-item .btn-remove-photo {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.photo-upload-item .overlay {
|
||||
@@ -270,10 +285,20 @@ body {
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 60%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-status-text {
|
||||
color: white;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.photo-upload-item .error-overlay {
|
||||
background: rgb(224 108 117 / 80%);
|
||||
cursor: pointer;
|
||||
@@ -301,7 +326,7 @@ body {
|
||||
}
|
||||
|
||||
.photo-upload-item .btn-remove-photo:hover {
|
||||
background: var(--marker-color-primary);
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
@@ -460,6 +485,10 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header.no-border {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
@@ -567,6 +596,67 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-content details .details-content.form-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.relay-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.relay-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-remove-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-remove-relay svg {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
background-color: var(--danger-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-link.reset-relays {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -595,7 +685,7 @@ body {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
@@ -639,6 +729,11 @@ select.form-control {
|
||||
}
|
||||
|
||||
.settings-section .form-group {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section .form-group:first-of-type {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -692,12 +787,19 @@ select.form-control {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.meta-info a {
|
||||
.meta-info a,
|
||||
.meta-info .btn-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meta-info a:hover {
|
||||
.meta-info a:hover,
|
||||
.meta-info .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -791,6 +893,164 @@ abbr[title] {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.photo-carousel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-carousel.inline {
|
||||
margin: -1rem -1rem 1rem;
|
||||
}
|
||||
|
||||
.photo-carousel-track {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.photo-carousel-track::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
flex: 0 0 100%;
|
||||
scroll-snap-align: start;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.place-header-photo-blur {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.place-header-photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
z-index: 1; /* Stay above blurhash */
|
||||
}
|
||||
|
||||
.place-header-photo.landscape {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.place-header-photo.portrait {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.place-header-photo.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.place-header-photo.loaded-instant {
|
||||
opacity: 1;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.carousel-nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgb(0 0 0 / 50%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
background-color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.photo-carousel:hover .carousel-nav-btn:not(.disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-nav-btn:not(.disabled):hover {
|
||||
background: rgb(0 0 0 / 80%);
|
||||
}
|
||||
|
||||
.carousel-nav-btn.disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-nav-btn.prev {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.carousel-nav-btn.next {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-carousel.inline .photo-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide {
|
||||
flex: 0 0 auto;
|
||||
height: 100px;
|
||||
width: auto;
|
||||
scroll-snap-align: none;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide.landscape {
|
||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-slide.portrait {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-placeholder {
|
||||
display: block;
|
||||
background-color: var(--hover-bg);
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .place-header-photo.landscape,
|
||||
.photo-carousel.inline .place-header-photo.portrait {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-carousel.inline .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.place-details h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
@@ -824,6 +1084,7 @@ abbr[title] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -967,6 +1228,7 @@ abbr[title] {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.app-logo-icon svg {
|
||||
@@ -1111,10 +1373,10 @@ span.icon {
|
||||
@media (width <= 768px) {
|
||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: 50%; /* Reset desktop shift */
|
||||
top: 25%;
|
||||
top: calc(25% + 30px); /* 30px approx half header height */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1519,6 +1781,13 @@ button.create-place {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-connect-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.nostr-connect-status {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
@@ -1561,6 +1830,12 @@ button.create-place {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
cursor: pointer;
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
.close-modal-btn.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.place-photo-upload h2 {
|
||||
@@ -1579,11 +1854,6 @@ button.create-place {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #eef;
|
||||
color: #00c;
|
||||
}
|
||||
|
||||
.preview-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1597,3 +1867,163 @@ button.create-place {
|
||||
max-width: 100%;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Photo Gallery */
|
||||
.photo-gallery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 90%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .close-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 10;
|
||||
color: white;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .close-btn {
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .main-photo-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .main-photo-container {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .main-photo-container img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Photo Carousel: Gallery Main Variant */
|
||||
.photo-carousel.gallery-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .photo-carousel-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .carousel-slide {
|
||||
height: 100%;
|
||||
flex: 0 0 100%;
|
||||
aspect-ratio: auto;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-main .place-header-photo-blur,
|
||||
.photo-carousel.gallery-main .place-header-photo.landscape,
|
||||
.photo-carousel.gallery-main .place-header-photo.portrait {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Photo Carousel: Gallery Thumbnails Variant */
|
||||
.photo-carousel.gallery-thumbnails {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .photo-carousel-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
gap: 4px;
|
||||
scroll-snap-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
scroll-snap-align: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
|
||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
|
||||
.photo-carousel.gallery-thumbnails .carousel-slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
|
||||
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component {
|
||||
@tracked isAppMenuOpen = false;
|
||||
|
||||
get isSidebarOpen() {
|
||||
// We consider the sidebar "open" if we are in search or place routes.
|
||||
// We consider the sidebar "open" if we are in search or place routes AND it's visible.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
return (
|
||||
this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search'
|
||||
this.mapUi.isSidebarVisible &&
|
||||
(this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component {
|
||||
handleOutsideClick() {
|
||||
if (this.isAppMenuOpen) {
|
||||
this.closeAppMenu();
|
||||
} else if (this.router.currentRouteName === 'search') {
|
||||
this.router.transitionTo('index');
|
||||
} else if (this.router.currentRouteName === 'place') {
|
||||
// If in place route, decide if we want to go back to search or index
|
||||
// For now, let's go to index or maybe back to search if search params exist?
|
||||
// Simplest behavior: clear selection
|
||||
this.router.transitionTo('index');
|
||||
} else if (
|
||||
this.router.currentRouteName === 'search' ||
|
||||
this.router.currentRouteName === 'place'
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component {
|
||||
if (place === null) {
|
||||
// If we have an active search context, return to it (UP navigation)
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
@@ -88,23 +89,26 @@ export default class PlaceTemplate extends Component {
|
||||
}
|
||||
} else {
|
||||
// If a place is selected (unlikely in this view, but possible if we add related links)
|
||||
this.mapUi.showSidebar();
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
// Clear search results so we don't fall back to the list
|
||||
this.router.transitionTo('index');
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
|
||||
<template>
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
||||
@name="x"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
</div>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
{{on "click" this.close}}
|
||||
><Icon @name="x" @size={{20}} @color="#333" /></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
this.mapUi.returnToSearch = true;
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
// We don't need to manually set currentSearch here because
|
||||
// it was already set in the route's setupController
|
||||
this.router.transitionTo('place', place);
|
||||
@@ -19,14 +21,16 @@ export default class SearchTemplate extends Component {
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
|
||||
<template>
|
||||
<PlacesSidebar
|
||||
@places={{@model}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
{{#if this.mapUi.isSidebarVisible}}
|
||||
<PlacesSidebar
|
||||
@places={{this.mapUi.searchResults}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import camera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import database from 'feather-icons/dist/icons/database.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||
@@ -42,7 +45,9 @@ import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield
|
||||
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
||||
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
|
||||
import bridge from '@waysidemapping/pinhead/dist/icons/bridge.svg?raw';
|
||||
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||
@@ -73,6 +78,7 @@ import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||
@@ -100,6 +106,7 @@ import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_str
|
||||
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
|
||||
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
|
||||
@@ -109,6 +116,7 @@ import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
@@ -129,9 +137,13 @@ const ICONS = {
|
||||
bookmark,
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bridge,
|
||||
bus,
|
||||
camera,
|
||||
'feather-camera': featherCamera,
|
||||
'check-square': checkSquare,
|
||||
'chevron-left': chevronLeft,
|
||||
'chevron-right': chevronRight,
|
||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||
climbing_wall: climbingWall,
|
||||
check,
|
||||
@@ -149,6 +161,7 @@ const ICONS = {
|
||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||
croissant,
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
database,
|
||||
donut,
|
||||
edit,
|
||||
eyeglasses,
|
||||
@@ -170,6 +183,7 @@ const ICONS = {
|
||||
heart,
|
||||
home,
|
||||
'ice-cream-on-cone': iceCreamOnCone,
|
||||
'industrial-building': industrialBuilding,
|
||||
info,
|
||||
instagram,
|
||||
jewel,
|
||||
@@ -210,6 +224,7 @@ const ICONS = {
|
||||
'sailing-ship-in-water': sailingShipInWater,
|
||||
'scissors-open': scissorsOpen,
|
||||
'shipwreck-in-water': shipwreckInWater,
|
||||
'steam-train-on-railway-track': steamTrainOnRailwayTrack,
|
||||
'shopping-bag': shoppingBag,
|
||||
search,
|
||||
server,
|
||||
@@ -229,6 +244,7 @@ const ICONS = {
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
winding_way_wide: windingWayWide,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
x,
|
||||
|
||||
88
app/utils/nostr.js
Normal file
88
app/utils/nostr.js
Normal file
@@ -0,0 +1,88 @@
|
||||
export function normalizeRelayUrl(url) {
|
||||
if (!url) return '';
|
||||
let normalized = url.trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
|
||||
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
|
||||
normalized = 'wss://' + normalized;
|
||||
}
|
||||
|
||||
while (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {Array} events NIP-360 events
|
||||
* @returns {Array} Array of photo objects
|
||||
*/
|
||||
export function parsePlacePhotos(events) {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
// Sort by created_at ascending (oldest first)
|
||||
const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at);
|
||||
|
||||
const allPhotos = [];
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
// Find all imeta tags
|
||||
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||
for (const imeta of imetas) {
|
||||
let url = null;
|
||||
let thumbUrl = null;
|
||||
let blurhash = null;
|
||||
let isLandscape = false;
|
||||
let aspectRatio = 16 / 9; // default
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
url = tag.substring(4);
|
||||
} else if (tag.startsWith('thumb ')) {
|
||||
thumbUrl = tag.substring(6);
|
||||
} else if (tag.startsWith('blurhash ')) {
|
||||
blurhash = tag.substring(9);
|
||||
} else if (tag.startsWith('dim ')) {
|
||||
const dimStr = tag.substring(4);
|
||||
const [width, height] = dimStr.split('x').map(Number);
|
||||
if (width && height) {
|
||||
aspectRatio = width / height;
|
||||
if (width > height) {
|
||||
isLandscape = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
allPhotos.push({
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
isLandscape,
|
||||
aspectRatio,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allPhotos.length === 0) return [];
|
||||
|
||||
// Find the first landscape photo
|
||||
const firstLandscapeIndex = allPhotos.findIndex((p) => p.isLandscape);
|
||||
|
||||
if (firstLandscapeIndex > 0) {
|
||||
// Move the first landscape photo to the front
|
||||
const [firstLandscape] = allPhotos.splice(firstLandscapeIndex, 1);
|
||||
allPhotos.unshift(firstLandscape);
|
||||
}
|
||||
|
||||
return allPhotos;
|
||||
}
|
||||
@@ -109,6 +109,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||
|
||||
// Historic
|
||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
@@ -119,6 +122,12 @@ export const POI_ICON_RULES = [
|
||||
tags: { historic: 'monument' },
|
||||
icon: 'classical-building-with-dome-and-flag',
|
||||
},
|
||||
{ tags: { historic: 'folly' }, icon: 'classical-building' },
|
||||
{ tags: { historic: 'industrial' }, icon: 'industrial-building' },
|
||||
{
|
||||
tags: { historic: 'railway_station' },
|
||||
icon: 'steam-train-on-railway-track',
|
||||
},
|
||||
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||
|
||||
@@ -41,7 +41,7 @@ export const POI_CATEGORIES = [
|
||||
{
|
||||
id: 'things-to-do',
|
||||
label: 'Things to do',
|
||||
icon: 'camera',
|
||||
icon: 'feather-camera',
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
|
||||
|
||||
## Content
|
||||
|
||||
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
|
||||
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
|
||||
|
||||
## Tags
|
||||
|
||||
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
|
||||
|
||||
#### 3. `imeta` — Inline Media Metadata
|
||||
|
||||
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
|
||||
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` MAY also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
|
||||
|
||||
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
|
||||
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
|
||||
|
||||
```json
|
||||
[
|
||||
"imeta",
|
||||
"url https://example.com/photo.jpg",
|
||||
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||
"m image/jpeg",
|
||||
"dim 3024x4032",
|
||||
"dim 1440x1920",
|
||||
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
||||
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
|
||||
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
||||
]
|
||||
```
|
||||
@@ -83,10 +85,12 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
||||
|
||||
[
|
||||
"imeta",
|
||||
"url https://example.com/ramen.jpg",
|
||||
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||
"m image/jpeg",
|
||||
"dim 1080x1080",
|
||||
"dim 1920x1920",
|
||||
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
||||
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
|
||||
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||
],
|
||||
|
||||
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
||||
|
||||
## Rationale
|
||||
|
||||
### Kind 360
|
||||
|
||||
Easy to remember as a 360-degree view of places.
|
||||
|
||||
### Why not use NIP-68 (Picture-first feeds)?
|
||||
|
||||
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
|
||||
@@ -105,3 +113,7 @@ NIP-68 is designed for general-purpose social feeds (like Instagram). Place phot
|
||||
### Separation from Place Reviews
|
||||
|
||||
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
|
||||
|
||||
### Single Photo per Event
|
||||
|
||||
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.
|
||||
|
||||
@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
|
||||
|
||||
## Rationale
|
||||
|
||||
### Kind 30360
|
||||
|
||||
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
|
||||
|
||||
### No Place Field in Content
|
||||
|
||||
Avoids duplication and inconsistency with tags.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.19.1",
|
||||
"version": "1.21.2",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -88,7 +88,7 @@
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"remotestorage-widget": "^1.8.1",
|
||||
"remotestoragejs": "2.0.0-beta.8",
|
||||
"remotestoragejs": "2.0.0-beta.9",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
@@ -111,6 +111,7 @@
|
||||
"blurhash": "^2.0.5",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0",
|
||||
"nostr-idb": "^5.0.0",
|
||||
"oauth2-pkce": "^2.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.2"
|
||||
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
ember-lifeline:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
||||
nostr-idb:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
oauth2-pkce:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
@@ -193,8 +196,8 @@ importers:
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
remotestoragejs:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8
|
||||
specifier: 2.0.0-beta.9
|
||||
version: 2.0.0-beta.9
|
||||
sinon:
|
||||
specifier: ^21.0.1
|
||||
version: 21.0.1
|
||||
@@ -1717,9 +1720,6 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@20.14.0':
|
||||
resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==}
|
||||
|
||||
'@types/node@25.0.7':
|
||||
resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==}
|
||||
|
||||
@@ -1732,9 +1732,6 @@ packages:
|
||||
'@types/symlink-or-copy@1.2.2':
|
||||
resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
|
||||
|
||||
'@types/tv4@1.2.33':
|
||||
resolution: {integrity: sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -3709,6 +3706,9 @@ packages:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb@8.0.3:
|
||||
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -4405,6 +4405,9 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
nostr-idb@5.0.0:
|
||||
resolution: {integrity: sha512-w5y4AnHefZIwCCL11NryfM2xp3U0Ka4qVNQEYAjnQbPwyoV+bZTdwuPXHCdRDWvhOFP2bZr1WBegcsAmkBjrxQ==}
|
||||
|
||||
nostr-signer-capacitor-plugin@0.0.5:
|
||||
resolution: {integrity: sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==}
|
||||
peerDependencies:
|
||||
@@ -4897,8 +4900,8 @@ packages:
|
||||
remotestorage-widget@1.8.1:
|
||||
resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-rtyHTG2VbtiKTRmbwjponRf5VTPJMcHv/ijNid1zX48C0Z0F8ZCBBfkKD2QCxTQyQvCupkWNy3wuIu4HE+AEng==}
|
||||
remotestoragejs@2.0.0-beta.9:
|
||||
resolution: {integrity: sha512-d09ByL7ecbZLMuzl4mQ3SXMFlsCwvvINm6l1CfdR8ylvX9E1nsq44t8gmRxzW6GUS5cwonyYA4FRXYKEhARjTA==}
|
||||
|
||||
remove-types@1.0.0:
|
||||
resolution: {integrity: sha512-G7Hk1Q+UJ5DvlNAoJZObxANkBZGiGdp589rVcTW/tYqJWJ5rwfraSnKSQaETN8Epaytw8J40nS/zC7bcHGv36w==}
|
||||
@@ -5569,9 +5572,6 @@ packages:
|
||||
underscore@1.13.7:
|
||||
resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
@@ -5738,8 +5738,8 @@ packages:
|
||||
web-worker@1.5.0:
|
||||
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
|
||||
|
||||
webfinger.js@2.8.2:
|
||||
resolution: {integrity: sha512-Zqn9KXkGrD1tVEm029bVUIfmzef2KXs3G7OZrdqehDHtgv9YSxX1oy4RoPoMk2PHWIifwWCA0xwKZOAZqXMpfg==}
|
||||
webfinger.js@3.0.4:
|
||||
resolution: {integrity: sha512-5c15N1n4qCm/jGJjUt32mBdPVlSugLbAztIDNBpuDfukGz2E9NhmXPfLikayn2p3kcgEZsI/UOdOwVpxOr8qJA==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
@@ -7639,10 +7639,6 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@20.14.0':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@25.0.7':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -7656,8 +7652,6 @@ snapshots:
|
||||
|
||||
'@types/symlink-or-copy@1.2.2': {}
|
||||
|
||||
'@types/tv4@1.2.33': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
|
||||
@@ -10286,6 +10280,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb@8.0.3: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -11021,6 +11017,13 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
nostr-idb@5.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
idb: 8.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.2):
|
||||
dependencies:
|
||||
'@capacitor/core': 7.6.2
|
||||
@@ -11512,13 +11515,11 @@ snapshots:
|
||||
|
||||
remotestorage-widget@1.8.1: {}
|
||||
|
||||
remotestoragejs@2.0.0-beta.8:
|
||||
remotestoragejs@2.0.0-beta.9:
|
||||
dependencies:
|
||||
'@types/node': 20.14.0
|
||||
'@types/tv4': 1.2.33
|
||||
esm: 3.2.25
|
||||
tv4: 1.3.0
|
||||
webfinger.js: 2.8.2
|
||||
webfinger.js: 3.0.4
|
||||
xhr2: 0.2.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
@@ -12398,8 +12399,6 @@ snapshots:
|
||||
|
||||
underscore@1.13.7: {}
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
@@ -12551,7 +12550,7 @@ snapshots:
|
||||
|
||||
web-worker@1.5.0: {}
|
||||
|
||||
webfinger.js@2.8.2: {}
|
||||
webfinger.js@3.0.4: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
|
||||
1
release/assets/image-processor-Dj3-kZwI.js
Normal file
1
release/assets/image-processor-Dj3-kZwI.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
14
release/assets/main-CjxGWim8.js
Normal file
14
release/assets/main-CjxGWim8.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-M5C-HUrg.css
Normal file
1
release/assets/main-M5C-HUrg.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/negentropy-4SxsxSp-.js
Normal file
2
release/assets/negentropy-4SxsxSp-.js
Normal file
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
|
||||
<script type="module" crossorigin src="/assets/main-CjxGWim8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-M5C-HUrg.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -140,12 +140,14 @@ module('Acceptance | map search reset', function (hooks) {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Wait for transition to index
|
||||
// Wait for transition or UI update
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/',
|
||||
'Should have transitioned to index (closed sidebar)'
|
||||
|
||||
// Sidebar should be hidden, but we should stay on the search route
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.ok(
|
||||
currentURL().includes('category=coffee'),
|
||||
'Should have stayed on the search route with markers intact'
|
||||
);
|
||||
|
||||
// Second Click (Start new search)
|
||||
|
||||
@@ -95,8 +95,8 @@ module('Acceptance | navigation', function (hooks) {
|
||||
// Click the Close (X) button
|
||||
await click('.close-btn');
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
||||
});
|
||||
|
||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
||||
import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { Promise } from 'rsvp';
|
||||
|
||||
let photonResolve;
|
||||
let osmResolve;
|
||||
|
||||
class MockPhotonService extends Service {
|
||||
cancelAll() {}
|
||||
|
||||
async search(query) {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (query === 'slow') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
// Return a promise that we can manually resolve in the test
|
||||
// to avoid race conditions with native setTimeout
|
||||
return new Promise((resolve) => {
|
||||
photonResolve = () => {
|
||||
resolve([
|
||||
{
|
||||
title: 'Test Place',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
},
|
||||
]);
|
||||
};
|
||||
});
|
||||
}
|
||||
return [
|
||||
{
|
||||
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
|
||||
cancelAll() {}
|
||||
|
||||
async getCategoryPois(bounds, category) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (category === 'slow_category') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return new Promise((resolve) => {
|
||||
osmResolve = () => {
|
||||
resolve([]);
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
photonResolve = null;
|
||||
osmResolve = null;
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is set for text search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise so the task can finish deterministically
|
||||
photonResolve();
|
||||
|
||||
await searchPromise;
|
||||
await settled(); // Wait for ember-concurrency tasks to fully settle
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is set for category search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise
|
||||
osmResolve();
|
||||
|
||||
await catPromise;
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
|
||||
|
||||
// 4. Click the clear button (should be visible since input has value)
|
||||
await click('.search-clear-btn');
|
||||
// Wait for the click and transition to settle
|
||||
|
||||
// Verify loading state is cleared immediately
|
||||
assert.strictEqual(
|
||||
@@ -130,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
|
||||
'Loading state is cleared immediately after clicking clear'
|
||||
);
|
||||
|
||||
// Clean up the dangling promise
|
||||
if (photonResolve) {
|
||||
photonResolve();
|
||||
}
|
||||
|
||||
// Verify we are back on index (or at least query is gone)
|
||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
setupRenderingTest as upstreamSetupRenderingTest,
|
||||
setupTest as upstreamSetupTest,
|
||||
} from 'ember-qunit';
|
||||
import { setupNostrMocks } from './mock-nostr';
|
||||
|
||||
// This file exists to provide wrappers around ember-qunit's
|
||||
// test setup functions. This way, you can easily extend the setup that is
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
|
||||
function setupApplicationTest(hooks, options) {
|
||||
upstreamSetupApplicationTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
// Additional setup for application tests can be done here.
|
||||
//
|
||||
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
|
||||
|
||||
function setupRenderingTest(hooks, options) {
|
||||
upstreamSetupRenderingTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
// Additional setup for rendering tests can be done here.
|
||||
}
|
||||
|
||||
function setupTest(hooks, options) {
|
||||
upstreamSetupTest(hooks, options);
|
||||
setupNostrMocks(hooks);
|
||||
|
||||
// Additional setup for unit tests can be done here.
|
||||
}
|
||||
|
||||
96
tests/helpers/mock-nostr.js
Normal file
96
tests/helpers/mock-nostr.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { Promise } from 'rsvp';
|
||||
|
||||
export class MockNostrAuthService extends Service {
|
||||
@tracked pubkey = null;
|
||||
@tracked signerType = null;
|
||||
@tracked connectStatus = null;
|
||||
@tracked connectUri = null;
|
||||
|
||||
get isConnected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get isMobile() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get signer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async connectWithExtension() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async connectWithApp() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
export class MockNostrDataService extends Service {
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
|
||||
store = {
|
||||
add: () => {},
|
||||
};
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get activeWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultWriteRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get userDisplayName() {
|
||||
return 'Mock User';
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
loadPhotosForPlace() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
loadPlacePhotos() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true }];
|
||||
}
|
||||
}
|
||||
|
||||
export function setupNostrMocks(hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:nostrRelay', MockNostrRelayService);
|
||||
});
|
||||
}
|
||||
112
tests/integration/components/photo-carousel-test.gjs
Normal file
112
tests/integration/components/photo-carousel-test.gjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import PhotoCarousel from 'marco/components/photo-carousel';
|
||||
|
||||
module('Integration | Component | photo-carousel', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders gracefully with no photos', async function (assert) {
|
||||
this.photos = [];
|
||||
|
||||
await render(
|
||||
<template><PhotoCarousel @photos={{this.photos}} /></template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-carousel')
|
||||
.doesNotExist('it does not render the wrapper when there are no photos');
|
||||
});
|
||||
|
||||
test('it renders a single photo without navigation chevrons', async function (assert) {
|
||||
this.photos = [
|
||||
{
|
||||
url: 'photo1.jpg',
|
||||
thumbUrl: 'thumb1.jpg',
|
||||
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||
ratio: 1.5,
|
||||
isLandscape: true,
|
||||
},
|
||||
];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div class="test-container">
|
||||
<PhotoCarousel @photos={{this.photos}} />
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.photo-carousel').exists('it renders the wrapper');
|
||||
assert
|
||||
.dom('.carousel-slide:not(.carousel-placeholder)')
|
||||
.exists({ count: 1 }, 'it renders one real photo slide');
|
||||
assert
|
||||
.dom('.carousel-placeholder')
|
||||
.exists({ count: 1 }, 'it renders one placeholder');
|
||||
assert
|
||||
.dom('img.place-header-photo')
|
||||
.hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
|
||||
|
||||
// There should be no chevrons when there's only 1 photo
|
||||
assert
|
||||
.dom('.carousel-nav-btn')
|
||||
.doesNotExist('it does not render chevrons for a single photo');
|
||||
});
|
||||
|
||||
test('it renders multiple photos and shows chevrons', async function (assert) {
|
||||
this.photos = [
|
||||
{
|
||||
url: 'photo1.jpg',
|
||||
thumbUrl: 'thumb1.jpg',
|
||||
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||
ratio: 1.5,
|
||||
isLandscape: true,
|
||||
},
|
||||
{
|
||||
url: 'photo2.jpg',
|
||||
thumbUrl: 'thumb2.jpg',
|
||||
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||
ratio: 1.0,
|
||||
isLandscape: false,
|
||||
},
|
||||
{
|
||||
url: 'photo3.jpg',
|
||||
thumbUrl: 'thumb3.jpg',
|
||||
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||
ratio: 0.8,
|
||||
isLandscape: false,
|
||||
},
|
||||
];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div class="test-container">
|
||||
<PhotoCarousel @photos={{this.photos}} />
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
assert.dom('.carousel-slide').exists({ count: 3 }, 'it renders all slides');
|
||||
assert
|
||||
.dom('.carousel-nav-btn')
|
||||
.exists({ count: 2 }, 'it renders both chevrons');
|
||||
|
||||
// Initially, it shouldn't be able to scroll left
|
||||
assert
|
||||
.dom('.carousel-nav-btn.prev')
|
||||
.hasClass('disabled', 'the prev button is disabled initially');
|
||||
assert
|
||||
.dom('.carousel-nav-btn.next')
|
||||
.doesNotHaveClass('disabled', 'the next button is enabled initially');
|
||||
|
||||
// We can't perfectly test native scroll behavior easily in JSDOM/QUnit without mocking the DOM elements' scroll properties,
|
||||
// but we can test that clicking the next button triggers the scrolling method.
|
||||
// However, since we mock scrollLeft in the component logic implicitly via template action, let's at least ensure clicking doesn't throw.
|
||||
await click('.carousel-nav-btn.next');
|
||||
|
||||
assert.ok(true, 'clicking next button does not throw');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ module('Unit | Route | place', function (hooks) {
|
||||
selectPlaceCalled = true;
|
||||
}
|
||||
stopSearch() {}
|
||||
showSidebar() {}
|
||||
hideSidebar() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
@@ -76,6 +78,8 @@ module('Unit | Route | place', function (hooks) {
|
||||
class MapUiStub extends Service {
|
||||
selectPlace() {}
|
||||
stopSearch() {}
|
||||
showSidebar() {}
|
||||
hideSidebar() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
@@ -110,6 +114,8 @@ module('Unit | Route | place', function (hooks) {
|
||||
class MapUiStub extends Service {
|
||||
selectPlace() {}
|
||||
stopSearch() {}
|
||||
showSidebar() {}
|
||||
hideSidebar() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
@@ -155,6 +161,8 @@ module('Unit | Route | place', function (hooks) {
|
||||
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||
}
|
||||
stopSearch() {}
|
||||
showSidebar() {}
|
||||
hideSidebar() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:storage', StorageStub);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [
|
||||
ember(),
|
||||
|
||||
Reference in New Issue
Block a user