Compare commits
42 Commits
a2a61b0fec
...
v1.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
|
a89ba904c8
|
|||
|
4c540bc713
|
|||
|
bb2411972f
|
|||
|
5cd384cf3a
|
|||
|
ec31d1a59b
|
|||
|
4f55f26851
|
|||
|
b7cce6eb7e
|
|||
|
79777fb51a
|
|||
|
1ed66ca744
|
@@ -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;
|
||||
@@ -1033,7 +1034,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 +1079,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { on } from '@ember/modifier';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import qrCode from '../modifiers/qr-code';
|
||||
|
||||
@@ -6,12 +6,14 @@ 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 PlacePhotosCarousel from './place-photos-carousel';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -19,10 +21,12 @@ 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 newlyUploadedPhotoId = null;
|
||||
|
||||
@action
|
||||
openPhotoUploadModal(e) {
|
||||
@@ -37,8 +41,19 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
closePhotoUploadModal() {
|
||||
closePhotoUploadModal(eventId) {
|
||||
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 +91,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
|
||||
@@ -339,6 +364,12 @@ export default class PlaceDetails extends Component {
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<PlacePhotosCarousel
|
||||
@photos={{this.photos}}
|
||||
@name={{this.name}}
|
||||
@resetKey={{this.place.osmId}}
|
||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||
/>
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
@@ -540,9 +571,13 @@ export default class PlaceDetails extends Component {
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="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>
|
||||
@@ -551,7 +586,10 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
{{#if this.isPhotoUploadModalOpen}}
|
||||
<Modal @onClose={{this.closePhotoUploadModal}}>
|
||||
<PlacePhotoUpload @place={{this.saveablePlace}} />
|
||||
<PlacePhotoUpload
|
||||
@place={{this.saveablePlace}}
|
||||
@onClose={{this.closePhotoUploadModal}}
|
||||
/>
|
||||
</Modal>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
|
||||
export default class PlacePhotoItem extends Component {
|
||||
@service blossom;
|
||||
|
||||
@tracked thumbnailUrl = '';
|
||||
@tracked error = '';
|
||||
@tracked isUploaded = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.args.file) {
|
||||
this.thumbnailUrl = URL.createObjectURL(this.args.file);
|
||||
this.uploadTask.perform(this.args.file);
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
if (this.thumbnailUrl) {
|
||||
URL.revokeObjectURL(this.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
uploadTask = task(async (file) => {
|
||||
this.error = '';
|
||||
try {
|
||||
const dim = await new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(`${img.width}x${img.height}`);
|
||||
img.onerror = () => resolve('');
|
||||
img.src = this.thumbnailUrl;
|
||||
});
|
||||
|
||||
const result = await this.blossom.upload(file);
|
||||
this.isUploaded = true;
|
||||
|
||||
if (this.args.onSuccess) {
|
||||
this.args.onSuccess({
|
||||
file,
|
||||
url: result.url,
|
||||
fallbackUrls: result.fallbackUrls,
|
||||
type: result.type,
|
||||
dim,
|
||||
hash: result.hash,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-item
|
||||
{{if this.uploadTask.isRunning 'is-uploading'}}
|
||||
{{if this.error 'has-error'}}"
|
||||
>
|
||||
<img src={{this.thumbnailUrl}} alt="thumbnail" class="photo-item-img" />
|
||||
|
||||
{{#if this.uploadTask.isRunning}}
|
||||
<div class="photo-item-overlay">
|
||||
<Icon
|
||||
@name="loading-ring"
|
||||
@size={{24}}
|
||||
@color="white"
|
||||
class="spin-animation"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.error}}
|
||||
<div class="photo-item-overlay error-overlay" title={{this.error}}>
|
||||
<Icon @name="alert-circle" @size={{24}} @color="white" />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isUploaded}}
|
||||
<div class="photo-item-overlay success-overlay">
|
||||
<Icon @name="check" @size={{24}} @color="white" />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-photo"
|
||||
title="Remove photo"
|
||||
{{on "click" (fn @onRemove @file)}}
|
||||
>
|
||||
<Icon @name="x" @size={{16}} @color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
154
app/components/place-photo-upload-item.gjs
Normal file
154
app/components/place-photo-upload-item.gjs
Normal file
@@ -0,0 +1,154 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
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 MAX_THUMBNAIL_DIMENSION = 350;
|
||||
const THUMBNAIL_QUALITY = 0.9;
|
||||
|
||||
export default class PlacePhotoUploadItem extends Component {
|
||||
@service blossom;
|
||||
@service imageProcessor;
|
||||
@service toast;
|
||||
|
||||
@tracked thumbnailUrl = '';
|
||||
@tracked blurhash = '';
|
||||
@tracked error = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.args.file) {
|
||||
this.thumbnailUrl = URL.createObjectURL(this.args.file);
|
||||
this.uploadTask.perform(this.args.file);
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
if (this.thumbnailUrl) {
|
||||
URL.revokeObjectURL(this.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
showErrorToast() {
|
||||
if (this.error) {
|
||||
this.toast.show(this.error);
|
||||
}
|
||||
}
|
||||
|
||||
uploadTask = task(async (file) => {
|
||||
this.error = '';
|
||||
try {
|
||||
// 1. Process main image and generate blurhash in worker
|
||||
const mainData = await this.imageProcessor.process(
|
||||
file,
|
||||
MAX_IMAGE_DIMENSION,
|
||||
IMAGE_QUALITY,
|
||||
true // computeBlurhash
|
||||
);
|
||||
|
||||
this.blurhash = mainData.blurhash;
|
||||
|
||||
// 2. Process thumbnail (no blurhash needed)
|
||||
const thumbData = await this.imageProcessor.process(
|
||||
file,
|
||||
MAX_THUMBNAIL_DIMENSION,
|
||||
THUMBNAIL_QUALITY,
|
||||
false
|
||||
);
|
||||
|
||||
// 3. Upload main image
|
||||
// 4. Upload thumbnail
|
||||
let mainResult, thumbResult;
|
||||
const isMobileDevice = isMobile();
|
||||
|
||||
if (isMobileDevice) {
|
||||
// Mobile: sequential uploads to preserve bandwidth and memory
|
||||
mainResult = await this.blossom.upload(mainData.blob, {
|
||||
sequential: true,
|
||||
});
|
||||
thumbResult = await this.blossom.upload(thumbData.blob, {
|
||||
sequential: true,
|
||||
});
|
||||
} else {
|
||||
// Desktop: concurrent uploads
|
||||
const mainUploadPromise = this.blossom.upload(mainData.blob);
|
||||
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
|
||||
|
||||
[mainResult, thumbResult] = await Promise.all([
|
||||
mainUploadPromise,
|
||||
thumbUploadPromise,
|
||||
]);
|
||||
}
|
||||
|
||||
if (this.args.onSuccess) {
|
||||
this.args.onSuccess({
|
||||
file,
|
||||
url: mainResult.url,
|
||||
fallbackUrls: mainResult.fallbackUrls,
|
||||
thumbUrl: thumbResult.url,
|
||||
blurhash: mainData.blurhash,
|
||||
type: 'image/jpeg',
|
||||
dim: mainData.dim,
|
||||
hash: mainResult.hash,
|
||||
thumbHash: thumbResult.hash,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-upload-item
|
||||
{{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}}
|
||||
<div class="overlay">
|
||||
<Icon
|
||||
@name="loading-ring"
|
||||
@size={{24}}
|
||||
@color="white"
|
||||
class="spin-animation"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.error}}
|
||||
<button
|
||||
type="button"
|
||||
class="overlay error-overlay"
|
||||
title={{this.error}}
|
||||
{{on "click" this.showErrorToast}}
|
||||
>
|
||||
<Icon @name="alert-circle" @size={{24}} @color="white" />
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-photo"
|
||||
title="Remove photo"
|
||||
{{on "click" (fn @onRemove @file)}}
|
||||
>
|
||||
<Icon @name="x" @size={{16}} @color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { on } from '@ember/modifier';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { task } from 'ember-concurrency';
|
||||
import Geohash from 'latlon-geohash';
|
||||
import PlacePhotoItem from './place-photo-item';
|
||||
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||
import Icon from '#components/icon';
|
||||
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 file = null;
|
||||
@tracked uploadedPhoto = null;
|
||||
@tracked status = '';
|
||||
@tracked error = '';
|
||||
@tracked isPublishing = false;
|
||||
@@ -33,13 +34,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
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
this.addFiles(event.target.files);
|
||||
this.addFile(event.target.files[0]);
|
||||
event.target.value = ''; // Reset input
|
||||
}
|
||||
|
||||
@@ -59,39 +60,47 @@ 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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (!photoData.hash) return;
|
||||
await this.blossom.delete(photoData.hash);
|
||||
if (photoData.hash) {
|
||||
await this.blossom.delete(photoData.hash);
|
||||
}
|
||||
if (photoData.thumbHash) {
|
||||
await this.blossom.delete(photoData.thumbHash);
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
|
||||
}
|
||||
@@ -133,25 +142,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}`];
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
imeta.push(`url ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
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');
|
||||
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,
|
||||
@@ -164,13 +181,19 @@ 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');
|
||||
this.status = '';
|
||||
|
||||
// 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.onClose) {
|
||||
this.args.onClose(event.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Failed to publish: ' + e.message;
|
||||
this.status = '';
|
||||
@@ -181,7 +204,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">
|
||||
@@ -195,36 +218,13 @@ export default class PlacePhotoUpload extends Component {
|
||||
</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}} />
|
||||
<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|}}
|
||||
<PlacePhotoItem
|
||||
@file={{file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
{{/each}}
|
||||
<PlacePhotoUploadItem
|
||||
@file={{this.file}}
|
||||
@onSuccess={{this.handleUploadSuccess}}
|
||||
@onRemove={{this.removeFile}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -236,11 +236,29 @@ export default class PlacePhotoUpload extends Component {
|
||||
{{#if this.isPublishing}}
|
||||
Publishing...
|
||||
{{else}}
|
||||
Publish
|
||||
{{this.files.length}}
|
||||
Photo(s)
|
||||
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>
|
||||
|
||||
188
app/components/place-photos-carousel.gjs
Normal file
188
app/components/place-photos-carousel.gjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import Blurhash from './blurhash';
|
||||
import Icon from './icon';
|
||||
import fadeInImage from '../modifiers/fade-in-image';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default class PlacePhotosCarousel extends Component {
|
||||
@tracked canScrollLeft = false;
|
||||
@tracked canScrollRight = false;
|
||||
|
||||
carouselElement = null;
|
||||
|
||||
get photos() {
|
||||
return this.args.photos || [];
|
||||
}
|
||||
|
||||
get showChevrons() {
|
||||
return this.photos.length > 1;
|
||||
}
|
||||
|
||||
get cannotScrollLeft() {
|
||||
return !this.canScrollLeft;
|
||||
}
|
||||
|
||||
get cannotScrollRight() {
|
||||
return !this.canScrollRight;
|
||||
}
|
||||
|
||||
lastResetKey = null;
|
||||
|
||||
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
|
||||
this.lastResetKey = resetKey;
|
||||
element.scrollLeft = 0;
|
||||
setTimeout(() => this.updateScrollState(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
this.lastEventId = eventId;
|
||||
// Allow DOM to update first since the photo was *just* added to the store
|
||||
setTimeout(() => {
|
||||
const targetSlide = element.querySelector(
|
||||
`[data-event-id="${eventId}"]`
|
||||
);
|
||||
if (targetSlide) {
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
setupCarousel = modifier((element) => {
|
||||
this.carouselElement = element;
|
||||
|
||||
// Defer the initial calculation slightly to ensure CSS and images have applied
|
||||
setTimeout(() => {
|
||||
this.updateScrollState();
|
||||
}, 50);
|
||||
|
||||
let resizeObserver;
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.unobserve(element);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
updateScrollState() {
|
||||
if (!this.carouselElement) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
|
||||
// tolerance of 1px for floating point rounding issues
|
||||
this.canScrollLeft = scrollLeft > 1;
|
||||
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||
}
|
||||
|
||||
@action
|
||||
scrollLeft() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: -this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollRight() {
|
||||
if (!this.carouselElement) return;
|
||||
this.carouselElement.scrollBy({
|
||||
left: this.carouselElement.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.photos.length}}
|
||||
<div class="place-photos-carousel-wrapper">
|
||||
<div
|
||||
class="place-photos-carousel-track"
|
||||
{{this.setupCarousel}}
|
||||
{{this.resetScrollPosition @resetKey}}
|
||||
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||
{{on "scroll" this.updateScrollState}}
|
||||
>
|
||||
{{#each this.photos as |photo|}}
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<div
|
||||
class="carousel-slide"
|
||||
style={{photo.style}}
|
||||
data-event-id={{photo.eventId}}
|
||||
>
|
||||
{{#if photo.blurhash}}
|
||||
<Blurhash
|
||||
@hash={{photo.blurhash}}
|
||||
@width={{32}}
|
||||
@height={{18}}
|
||||
class="place-header-photo-blur"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if photo.isLandscape}}
|
||||
<picture>
|
||||
{{#if photo.thumbUrl}}
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
data-srcset={{photo.thumbUrl}}
|
||||
/>
|
||||
{{/if}}
|
||||
<img
|
||||
data-src={{photo.url}}
|
||||
class="place-header-photo landscape"
|
||||
alt={{@name}}
|
||||
{{fadeInImage photo.url}}
|
||||
/>
|
||||
</picture>
|
||||
{{else}}
|
||||
{{! Portrait uses thumb everywhere if available }}
|
||||
<img
|
||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||
class="place-header-photo portrait"
|
||||
alt={{@name}}
|
||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="carousel-placeholder"></div>
|
||||
</div>
|
||||
|
||||
{{#if this.showChevrons}}
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn prev
|
||||
{{if this.cannotScrollLeft 'disabled'}}"
|
||||
{{on "click" this.scrollLeft}}
|
||||
disabled={{this.cannotScrollLeft}}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<Icon @name="chevron-left" @color="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-nav-btn next
|
||||
{{if this.cannotScrollRight 'disabled'}}"
|
||||
{{on "click" this.scrollRight}}
|
||||
disabled={{this.cannotScrollRight}}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<Icon @name="chevron-right" @color="currentColor" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -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,6 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
});
|
||||
75
app/modifiers/fade-in-image.js
Normal file
75
app/modifiers/fade-in-image.js
Normal file
@@ -0,0 +1,75 @@
|
||||
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 handleLoad = () => {
|
||||
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||
if (!element.classList.contains('loaded-instant')) {
|
||||
element.classList.add('loaded');
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -119,14 +141,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 };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
|
||||
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
|
||||
|
||||
@@ -20,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) {
|
||||
@@ -74,39 +82,70 @@ export default class BlossomService extends Service {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async upload(file) {
|
||||
async upload(file, options = { sequential: false }) {
|
||||
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
let hashBuffer;
|
||||
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
crypto.subtle &&
|
||||
crypto.subtle.digest
|
||||
) {
|
||||
hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
} else {
|
||||
hashBuffer = sha256(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
const payloadHash = bufferToHex(hashBuffer);
|
||||
|
||||
const servers = this.servers;
|
||||
const mainServer = servers[0];
|
||||
const fallbackServers = servers.slice(1);
|
||||
|
||||
// Start all uploads concurrently
|
||||
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
|
||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||
this._uploadToServer(file, payloadHash, serverUrl)
|
||||
);
|
||||
|
||||
// Main server MUST succeed
|
||||
const mainResult = await mainPromise;
|
||||
|
||||
// Fallback servers can fail, but we log the warnings
|
||||
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||
const fallbackUrls = [];
|
||||
let mainResult;
|
||||
|
||||
for (let i = 0; i < fallbackResults.length; i++) {
|
||||
const result = fallbackResults[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
fallbackUrls.push(result.value.url);
|
||||
} else {
|
||||
console.warn(
|
||||
`Fallback upload to ${fallbackServers[i]} failed:`,
|
||||
result.reason
|
||||
);
|
||||
if (options.sequential) {
|
||||
// Sequential upload logic
|
||||
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
|
||||
|
||||
for (const serverUrl of fallbackServers) {
|
||||
try {
|
||||
const result = await this._uploadToServer(
|
||||
file,
|
||||
payloadHash,
|
||||
serverUrl
|
||||
);
|
||||
fallbackUrls.push(result.url);
|
||||
} catch (error) {
|
||||
console.warn(`Fallback upload to ${serverUrl} failed:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Concurrent upload logic
|
||||
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
|
||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||
this._uploadToServer(file, payloadHash, serverUrl)
|
||||
);
|
||||
|
||||
// Main server MUST succeed
|
||||
mainResult = await mainPromise;
|
||||
|
||||
// Fallback servers can fail, but we log the warnings
|
||||
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||
|
||||
for (let i = 0; i < fallbackResults.length; i++) {
|
||||
const result = fallbackResults[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
fallbackUrls.push(result.value.url);
|
||||
} else {
|
||||
console.warn(
|
||||
`Fallback upload to ${fallbackServers[i]} failed:`,
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
app/services/image-processor.js
Normal file
129
app/services/image-processor.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import Service from '@ember/service';
|
||||
// We use the special Vite query parameter to load this as a web worker
|
||||
import Worker from '../workers/image-processor?worker';
|
||||
|
||||
export default class ImageProcessorService extends Service {
|
||||
_worker = null;
|
||||
_callbacks = new Map();
|
||||
_msgId = 0;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this._initWorker();
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
if (!this._worker && typeof Worker !== 'undefined') {
|
||||
try {
|
||||
this._worker = new Worker();
|
||||
this._worker.onmessage = this._handleMessage.bind(this);
|
||||
this._worker.onerror = this._handleError.bind(this);
|
||||
} catch (e) {
|
||||
console.warn('Failed to initialize image-processor worker:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(e) {
|
||||
const { id, success, blob, dim, blurhash, error } = e.data;
|
||||
const resolver = this._callbacks.get(id);
|
||||
|
||||
if (resolver) {
|
||||
this._callbacks.delete(id);
|
||||
if (success) {
|
||||
resolver.resolve({ blob, dim, blurhash });
|
||||
} else {
|
||||
resolver.reject(new Error(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleError(error) {
|
||||
console.error('Image Processor Worker Error:', error);
|
||||
// Reject all pending jobs
|
||||
for (const [, resolver] of this._callbacks.entries()) {
|
||||
resolver.reject(new Error('Worker crashed'));
|
||||
}
|
||||
this._callbacks.clear();
|
||||
// Restart the worker for future jobs
|
||||
this._worker.terminate();
|
||||
this._worker = null;
|
||||
this._initWorker();
|
||||
}
|
||||
|
||||
_getImageDimensions(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
const dimensions = { width: img.width, height: img.height };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(dimensions);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Could not read image dimensions'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async process(file, maxDimension, quality, computeBlurhash = false) {
|
||||
if (!this._worker) {
|
||||
// Fallback if worker initialization failed (e.g. incredibly old browsers)
|
||||
throw new Error('Image processor worker is not available.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get dimensions safely on the main thread
|
||||
const { width: origWidth, height: origHeight } =
|
||||
await this._getImageDimensions(file);
|
||||
|
||||
// 2. Calculate aspect-ratio preserving dimensions
|
||||
let targetWidth = origWidth;
|
||||
let targetHeight = origHeight;
|
||||
|
||||
if (origWidth > origHeight) {
|
||||
if (origWidth > maxDimension) {
|
||||
targetHeight = Math.round(origHeight * (maxDimension / origWidth));
|
||||
targetWidth = maxDimension;
|
||||
}
|
||||
} else {
|
||||
if (origHeight > maxDimension) {
|
||||
targetWidth = Math.round(origWidth * (maxDimension / origHeight));
|
||||
targetHeight = maxDimension;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send to worker for processing
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this._msgId;
|
||||
this._callbacks.set(id, { resolve, reject });
|
||||
|
||||
this._worker.postMessage({
|
||||
type: 'PROCESS_IMAGE',
|
||||
id,
|
||||
file,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
quality,
|
||||
computeBlurhash,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to process image: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
if (this._worker) {
|
||||
this._worker.terminate();
|
||||
this._worker = null;
|
||||
}
|
||||
this._callbacks.clear();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -19,12 +21,14 @@ export default class MapUiService extends Service {
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import {
|
||||
ExtensionSigner,
|
||||
@@ -14,6 +14,8 @@ const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||
|
||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||
|
||||
import { isMobile } from '../utils/device';
|
||||
|
||||
export default class NostrAuthService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrData;
|
||||
@@ -73,7 +75,7 @@ export default class NostrAuthService extends Service {
|
||||
}
|
||||
|
||||
get isMobile() {
|
||||
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||
return isMobile();
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
|
||||
@@ -1,35 +1,80 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { RelayPool } from 'applesauce-relay';
|
||||
export default class NostrRelayService extends Service {
|
||||
pool = new RelayPool();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -211,14 +213,14 @@ body {
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #3a4b5c;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 20px;
|
||||
margin: 1.5rem 0 1rem;
|
||||
background-color: rgb(255 255 255 / 2%);
|
||||
cursor: pointer;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.dropzone.is-dragging {
|
||||
@@ -230,14 +232,16 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
color: #a0aec0;
|
||||
color: #898989;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dropzone-label p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
@@ -245,28 +249,38 @@ 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-item {
|
||||
.photo-upload-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e262e;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-item-img {
|
||||
.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-item-overlay {
|
||||
.photo-upload-item .overlay,
|
||||
.photo-upload-item .btn-remove-photo {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.photo-upload-item .overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 60%);
|
||||
@@ -275,15 +289,16 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
.photo-upload-item .error-overlay {
|
||||
background: rgb(224 108 117 / 80%);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.success-overlay {
|
||||
background: rgb(152 195 121 / 60%);
|
||||
}
|
||||
|
||||
.btn-remove-photo {
|
||||
.photo-upload-item .btn-remove-photo {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
@@ -300,8 +315,8 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-remove-photo:hover {
|
||||
background: rgb(224 108 117 / 90%);
|
||||
.photo-upload-item .btn-remove-photo:hover {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
@@ -460,6 +475,10 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header.no-border {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
@@ -565,6 +584,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 +672,7 @@ body {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
@@ -639,6 +716,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 +774,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 +880,153 @@ abbr[title] {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.place-photos-carousel-wrapper {
|
||||
position: relative;
|
||||
margin: -1rem -1rem 1rem;
|
||||
}
|
||||
|
||||
.place-photos-carousel-track {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.place-photos-carousel-track::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
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;
|
||||
}
|
||||
|
||||
.place-photos-carousel-wrapper: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) {
|
||||
.place-photos-carousel-track {
|
||||
scroll-snap-type: none;
|
||||
gap: 2px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 auto;
|
||||
height: 100px;
|
||||
width: auto;
|
||||
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||
scroll-snap-align: none;
|
||||
}
|
||||
|
||||
.carousel-placeholder {
|
||||
display: block;
|
||||
background-color: var(--hover-bg);
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.place-header-photo.landscape,
|
||||
.place-header-photo.portrait {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.carousel-nav-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.place-details h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
@@ -967,6 +1203,7 @@ abbr[title] {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.app-logo-icon svg {
|
||||
@@ -1563,9 +1800,9 @@ button.create-place {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Place Photo Upload */
|
||||
.place-photo-upload h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
@@ -1584,12 +1821,6 @@ button.create-place {
|
||||
color: #00c;
|
||||
}
|
||||
|
||||
.connected-status {
|
||||
margin-bottom: 1rem;
|
||||
color: #080;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1603,3 +1834,17 @@ 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;
|
||||
}
|
||||
|
||||
4
app/utils/device.js
Normal file
4
app/utils/device.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isMobile() {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ 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 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';
|
||||
@@ -132,6 +135,8 @@ const ICONS = {
|
||||
bus,
|
||||
camera,
|
||||
'check-square': checkSquare,
|
||||
'chevron-left': chevronLeft,
|
||||
'chevron-right': chevronRight,
|
||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||
climbing_wall: climbingWall,
|
||||
check,
|
||||
@@ -149,6 +154,7 @@ const ICONS = {
|
||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||
croissant,
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
database,
|
||||
donut,
|
||||
edit,
|
||||
eyeglasses,
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
130
app/workers/image-processor.js
Normal file
130
app/workers/image-processor.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { encode } from 'blurhash';
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
// Ignore internal browser/Vite/extension pings that don't match our exact job signature
|
||||
if (e.data?.type !== 'PROCESS_IMAGE') return;
|
||||
|
||||
const { id, file, targetWidth, targetHeight, quality, computeBlurhash } =
|
||||
e.data;
|
||||
|
||||
try {
|
||||
let finalCanvas;
|
||||
let finalCtx;
|
||||
|
||||
// --- 1. Attempt Hardware Resizing (Happy Path) ---
|
||||
try {
|
||||
const resizedBitmap = await createImageBitmap(file, {
|
||||
resizeWidth: targetWidth,
|
||||
resizeHeight: targetHeight,
|
||||
resizeQuality: 'high',
|
||||
});
|
||||
|
||||
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||
finalCtx = finalCanvas.getContext('2d');
|
||||
if (!finalCtx) {
|
||||
throw new Error('Failed to get 2d context from OffscreenCanvas');
|
||||
}
|
||||
finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight);
|
||||
resizedBitmap.close();
|
||||
} catch (hwError) {
|
||||
console.warn(
|
||||
'Hardware resize failed, falling back to stepped software scaling:',
|
||||
hwError
|
||||
);
|
||||
|
||||
// --- 2. Fallback to Stepped Software Scaling (Robust Path) ---
|
||||
// Bypass Android File descriptor bug by reading into memory
|
||||
const buffer = await file.arrayBuffer();
|
||||
const blob = new Blob([buffer], { type: file.type });
|
||||
|
||||
const source = await createImageBitmap(blob);
|
||||
let srcWidth = source.width;
|
||||
let srcHeight = source.height;
|
||||
|
||||
let currentCanvas = new OffscreenCanvas(srcWidth, srcHeight);
|
||||
let ctx = currentCanvas.getContext('2d');
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(source, 0, 0);
|
||||
|
||||
// Step down by halves until near target
|
||||
while (
|
||||
currentCanvas.width * 0.5 > targetWidth &&
|
||||
currentCanvas.height * 0.5 > targetHeight
|
||||
) {
|
||||
const nextCanvas = new OffscreenCanvas(
|
||||
Math.floor(currentCanvas.width * 0.5),
|
||||
Math.floor(currentCanvas.height * 0.5)
|
||||
);
|
||||
const nextCtx = nextCanvas.getContext('2d');
|
||||
|
||||
nextCtx.imageSmoothingEnabled = true;
|
||||
nextCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
nextCtx.drawImage(
|
||||
currentCanvas,
|
||||
0,
|
||||
0,
|
||||
nextCanvas.width,
|
||||
nextCanvas.height
|
||||
);
|
||||
|
||||
currentCanvas = nextCanvas;
|
||||
}
|
||||
|
||||
// Final resize to exact target
|
||||
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||
finalCtx = finalCanvas.getContext('2d');
|
||||
|
||||
finalCtx.imageSmoothingEnabled = true;
|
||||
finalCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
finalCtx.drawImage(currentCanvas, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
source.close();
|
||||
}
|
||||
|
||||
// --- 3. Generate Blurhash (if requested) ---
|
||||
let blurhash = null;
|
||||
if (computeBlurhash) {
|
||||
try {
|
||||
const imageData = finalCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
blurhash = encode(imageData.data, targetWidth, targetHeight, 4, 3);
|
||||
} catch (blurhashError) {
|
||||
console.warn(
|
||||
'Could not generate blurhash (possible canvas fingerprinting protection):',
|
||||
blurhashError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Compress to JPEG Blob ---
|
||||
const finalBlob = await finalCanvas.convertToBlob({
|
||||
type: 'image/jpeg',
|
||||
quality: quality,
|
||||
});
|
||||
|
||||
const dim = `${targetWidth}x${targetHeight}`;
|
||||
|
||||
// --- 5. Send results back to main thread ---
|
||||
self.postMessage({
|
||||
id,
|
||||
success: true,
|
||||
blob: finalBlob,
|
||||
dim,
|
||||
blurhash,
|
||||
});
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
id,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.20.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -102,13 +102,16 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"applesauce-core": "^5.2.0",
|
||||
"applesauce-factory": "^4.0.0",
|
||||
"applesauce-relay": "^5.2.0",
|
||||
"applesauce-signers": "^5.2.0",
|
||||
"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"
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@waysidemapping/pinhead':
|
||||
specifier: ^15.20.0
|
||||
version: 15.20.0
|
||||
@@ -23,12 +26,18 @@ importers:
|
||||
applesauce-signers:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3)
|
||||
blurhash:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
ember-concurrency:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@babel/core@7.28.6)
|
||||
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
|
||||
@@ -2044,6 +2053,9 @@ packages:
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
blurhash@2.0.5:
|
||||
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
|
||||
|
||||
body-parser@1.20.4:
|
||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
@@ -3700,6 +3712,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'}
|
||||
@@ -4396,6 +4411,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:
|
||||
@@ -7537,7 +7555,7 @@ snapshots:
|
||||
'@scure/bip32@1.3.1':
|
||||
dependencies:
|
||||
'@noble/curves': 1.1.0
|
||||
'@noble/hashes': 1.3.1
|
||||
'@noble/hashes': 1.3.2
|
||||
'@scure/base': 1.1.1
|
||||
|
||||
'@scure/bip32@1.7.0':
|
||||
@@ -7554,7 +7572,7 @@ snapshots:
|
||||
|
||||
'@scure/bip39@1.2.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.3.1
|
||||
'@noble/hashes': 1.3.2
|
||||
'@scure/base': 1.1.1
|
||||
|
||||
'@scure/bip39@2.0.1':
|
||||
@@ -8053,6 +8071,8 @@ snapshots:
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
blurhash@2.0.5: {}
|
||||
|
||||
body-parser@1.20.4:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -10275,6 +10295,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb@8.0.3: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -11010,6 +11032,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
|
||||
|
||||
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-BA3LWr76.css
Normal file
1
release/assets/main-BA3LWr76.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
16
release/assets/main-CGySSjv6.js
Normal file
16
release/assets/main-CGySSjv6.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
2
release/assets/negentropy-CkK5_v5U.js
Normal file
2
release/assets/negentropy-CkK5_v5U.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-CGySSjv6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BA3LWr76.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
114
tests/integration/components/place-photos-carousel-test.gjs
Normal file
114
tests/integration/components/place-photos-carousel-test.gjs
Normal file
@@ -0,0 +1,114 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import PlacePhotosCarousel from 'marco/components/place-photos-carousel';
|
||||
|
||||
module('Integration | Component | place-photos-carousel', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders gracefully with no photos', async function (assert) {
|
||||
this.photos = [];
|
||||
|
||||
await render(
|
||||
<template><PlacePhotosCarousel @photos={{this.photos}} /></template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.place-photos-carousel-wrapper')
|
||||
.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">
|
||||
<PlacePhotosCarousel @photos={{this.photos}} />
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.place-photos-carousel-wrapper')
|
||||
.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">
|
||||
<PlacePhotosCarousel @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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user