Compare commits
58 Commits
a89ba904c8
...
v1.21.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
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,6 +8,7 @@ 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;
|
||||
@@ -71,7 +72,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">
|
||||
<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">
|
||||
<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">
|
||||
<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;
|
||||
@@ -781,14 +782,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 +854,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 +864,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 +885,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,7 +1041,7 @@ 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());
|
||||
@@ -1078,6 +1086,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 +1151,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>
|
||||
|
||||
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,13 @@ import { action } from '@ember/object';
|
||||
export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
@tracked isPhotoUploadModalOpen = false;
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
@tracked isPhotoUploadActive = false;
|
||||
@tracked isConnectingNostr = false;
|
||||
@tracked isGalleryOpen = false;
|
||||
@tracked selectedGalleryPhoto = null;
|
||||
|
||||
@action
|
||||
openPhotoUploadModal(e) {
|
||||
@@ -37,8 +43,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 +94,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 +358,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 +379,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 +586,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 +602,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 +619,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;
|
||||
@@ -15,16 +17,27 @@ export default class MapUiService extends Service {
|
||||
@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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -565,6 +594,64 @@ body {
|
||||
padding: 0 1.4rem 1rem;
|
||||
animation: details-slide-down 0.2s ease-out;
|
||||
font-size: 0.9rem;
|
||||
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 {
|
||||
@@ -595,7 +682,7 @@ body {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
@@ -639,6 +726,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 +784,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 +890,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;
|
||||
@@ -967,6 +1224,7 @@ abbr[title] {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.app-logo-icon svg {
|
||||
@@ -1111,10 +1369,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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1561,6 +1819,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 +1843,6 @@ button.create-place {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #eef;
|
||||
color: #00c;
|
||||
}
|
||||
|
||||
.preview-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1597,3 +1856,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.0",
|
||||
"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})}}}();
|
||||
1
release/assets/main-48yHGHPo.css
Normal file
1
release/assets/main-48yHGHPo.css
Normal file
File diff suppressed because one or more lines are too long
14
release/assets/main-B30qTale.js
Normal file
14
release/assets/main-B30qTale.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/negentropy-B7CRzqXr.js
Normal file
2
release/assets/negentropy-B7CRzqXr.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-B30qTale.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-48yHGHPo.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);
|
||||
|
||||
Reference in New Issue
Block a user