Merge pull request 'Connect Nostr, integrate place photos' (#45) from feature/nostr_place_reviews into master
Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
@@ -7,10 +7,14 @@ import Icon from '#components/icon';
|
|||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
import SearchBox from '#components/search-box';
|
import SearchBox from '#components/search-box';
|
||||||
import CategoryChips from '#components/category-chips';
|
import CategoryChips from '#components/category-chips';
|
||||||
|
import { and } from 'ember-truth-helpers';
|
||||||
|
import cachedImage from '../modifiers/cached-image';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
@tracked searchQuery = '';
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
@@ -64,9 +68,19 @@ export default class AppHeaderComponent extends Component {
|
|||||||
aria-label="User Menu"
|
aria-label="User Menu"
|
||||||
{{on "click" this.toggleUserMenu}}
|
{{on "click" this.toggleUserMenu}}
|
||||||
>
|
>
|
||||||
|
{{#if
|
||||||
|
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
|
||||||
|
}}
|
||||||
|
<img
|
||||||
|
{{cachedImage this.nostrData.profile.picture}}
|
||||||
|
class="user-avatar"
|
||||||
|
alt="User Avatar"
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
<div class="user-avatar-placeholder">
|
<div class="user-avatar-placeholder">
|
||||||
<Icon @name="user" @size={{20}} @color="white" />
|
<Icon @name="user" @size={{20}} @color="white" />
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{#if this.isUserMenuOpen}}
|
{{#if this.isUserMenuOpen}}
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
import { service } from '@ember/service';
|
||||||
import Icon from '#components/icon';
|
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 {
|
export default class AppMenuSettings extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateApi(event) {
|
updateSetting(key, event) {
|
||||||
this.settings.updateOverpassApi(event.target.value);
|
let value = event.target.value;
|
||||||
}
|
if (value === 'true') value = true;
|
||||||
|
if (value === 'false') value = false;
|
||||||
|
|
||||||
@action
|
this.settings.update(key, value);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -41,88 +32,9 @@ export default class AppMenuSettings extends Component {
|
|||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<div class="form-group">
|
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||||
<label for="show-quick-search">Quick search buttons visible</label>
|
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||||
<select
|
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||||
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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
}
|
||||||
219
app/components/app-menu/settings/nostr.gjs
Normal file
219
app/components/app-menu/settings/nostr.gjs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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 mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
@@ -1033,7 +1034,7 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMapMove = async () => {
|
handleMapMove = async () => {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const center = toLonLat(view.getCenter());
|
const center = toLonLat(view.getCenter());
|
||||||
@@ -1078,6 +1079,7 @@ export default class MapComponent extends Component {
|
|||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
this.mapUi.updateBounds(bbox);
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
|
this.nostrData.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
// Persist view to localStorage
|
// Persist view to localStorage
|
||||||
|
|||||||
43
app/components/modal.gjs
Normal file
43
app/components/modal.gjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from './icon';
|
||||||
|
|
||||||
|
export default class Modal extends Component {
|
||||||
|
@action
|
||||||
|
stopProp(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
{{on "click" this.close}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
{{on "click" this.stopProp}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-modal-btn btn-text"
|
||||||
|
{{on "click" this.close}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} />
|
||||||
|
</button>
|
||||||
|
{{yield}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
93
app/components/nostr-connect.gjs
Normal file
93
app/components/nostr-connect.gjs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { eq } from 'ember-truth-helpers';
|
||||||
|
import qrCode from '../modifiers/qr-code';
|
||||||
|
|
||||||
|
export default class NostrConnectComponent extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
get hasExtension() {
|
||||||
|
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectExtension() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.connectWithExtension();
|
||||||
|
this.toast.show('Nostr connected successfully');
|
||||||
|
if (this.args.onConnect) {
|
||||||
|
this.args.onConnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectApp() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.connectWithApp();
|
||||||
|
this.toast.show('Nostr connected successfully');
|
||||||
|
if (this.args.onConnect) {
|
||||||
|
this.args.onConnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="nostr-connect-modal">
|
||||||
|
<h2>Connect with Nostr</h2>
|
||||||
|
|
||||||
|
<div class="nostr-connect-options">
|
||||||
|
{{#if this.hasExtension}}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectExtension}}
|
||||||
|
>
|
||||||
|
Browser Extension (nos2x, Alby)
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="No Nostr extension found in your browser."
|
||||||
|
>
|
||||||
|
Browser Extension (Not Found)
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectApp}}
|
||||||
|
>
|
||||||
|
Mobile Signer App (Amber, etc.)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
|
||||||
|
<div class="nostr-connect-status">
|
||||||
|
{{#if this.nostrAuth.isMobile}}
|
||||||
|
<p>Waiting for you to approve the connection in your mobile signer
|
||||||
|
app...</p>
|
||||||
|
{{else}}
|
||||||
|
<p>Scan this QR code with a compatible Nostr signer app (like
|
||||||
|
Amber):</p>
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -6,17 +6,66 @@ import { humanizeOsmTag } from '../utils/format-text';
|
|||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
import { mapToStorageSchema } from '../utils/place-mapping';
|
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||||
import { getSocialInfo } from '../utils/social-links';
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
|
import { parsePlacePhotos } from '../utils/nostr';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
import PlaceListsManager from './place-lists-manager';
|
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 { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
|
@tracked isPhotoUploadModalOpen = false;
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
@tracked newlyUploadedPhotoId = null;
|
||||||
|
|
||||||
|
@action
|
||||||
|
openPhotoUploadModal(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
} else {
|
||||||
|
this.isPhotoUploadModalOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
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
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onNostrConnected() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
this.isPhotoUploadModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
get isSaved() {
|
get isSaved() {
|
||||||
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||||
@@ -42,6 +91,16 @@ export default class PlaceDetails extends Component {
|
|||||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
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
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.isSaved) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
@@ -305,6 +364,12 @@ export default class PlaceDetails extends Component {
|
|||||||
@onCancel={{this.cancelEditing}}
|
@onCancel={{this.cancelEditing}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<PlacePhotosCarousel
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@name={{this.name}}
|
||||||
|
@resetKey={{this.place.osmId}}
|
||||||
|
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||||
|
/>
|
||||||
<h3>{{this.name}}</h3>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
{{this.type}}
|
{{this.type}}
|
||||||
@@ -500,6 +565,38 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.osmUrl}}
|
||||||
|
<div class="meta-info">
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="camera" />
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
{{on "click" this.openPhotoUploadModal}}
|
||||||
|
>
|
||||||
|
Add a photo
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isPhotoUploadModalOpen}}
|
||||||
|
<Modal @onClose={{this.closePhotoUploadModal}}>
|
||||||
|
<PlacePhotoUpload
|
||||||
|
@place={{this.saveablePlace}}
|
||||||
|
@onClose={{this.closePhotoUploadModal}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</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>
|
||||||
|
}
|
||||||
265
app/components/place-photo-upload.gjs
Normal file
265
app/components/place-photo-upload.gjs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
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 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 file = null;
|
||||||
|
@tracked uploadedPhoto = null;
|
||||||
|
@tracked status = '';
|
||||||
|
@tracked error = '';
|
||||||
|
@tracked isPublishing = false;
|
||||||
|
@tracked isDragging = false;
|
||||||
|
|
||||||
|
get place() {
|
||||||
|
return this.args.place || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.place.title || 'this place';
|
||||||
|
}
|
||||||
|
|
||||||
|
get allUploaded() {
|
||||||
|
return (
|
||||||
|
this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleFileSelect(event) {
|
||||||
|
this.addFile(event.target.files[0]);
|
||||||
|
event.target.value = ''; // Reset input
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDragOver(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
if (event.dataTransfer.files.length > 0) {
|
||||||
|
this.addFile(event.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.uploadedPhoto = photoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeFile() {
|
||||||
|
if (this.uploadedPhoto) {
|
||||||
|
this.deletePhotoTask.perform(this.uploadedPhoto);
|
||||||
|
}
|
||||||
|
this.file = null;
|
||||||
|
this.uploadedPhoto = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async (photoData) => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
@action
|
||||||
|
async publish() {
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.error = 'You must connect Nostr first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.allUploaded) {
|
||||||
|
this.error = 'Please wait for all photos to finish uploading.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { osmId, lat, lon } = this.place;
|
||||||
|
const osmType = this.place.osmType || 'node';
|
||||||
|
|
||||||
|
if (!osmId) {
|
||||||
|
this.error = 'This place does not have a valid OSM ID.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'Publishing event...';
|
||||||
|
this.error = '';
|
||||||
|
this.isPublishing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
|
||||||
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 7)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = this.uploadedPhoto;
|
||||||
|
const imeta = ['imeta', `url ${photo.url}`];
|
||||||
|
|
||||||
|
imeta.push(`m ${photo.type}`);
|
||||||
|
|
||||||
|
if (photo.dim) {
|
||||||
|
imeta.push(`dim ${photo.dim}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
imeta.push('alt A photo of a place');
|
||||||
|
|
||||||
|
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||||
|
for (const fallbackUrl of photo.fallbackUrls) {
|
||||||
|
imeta.push(`fallback ${fallbackUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.thumbUrl) {
|
||||||
|
imeta.push(`thumb ${photo.thumbUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.blurhash) {
|
||||||
|
imeta.push(`blurhash ${photo.blurhash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(imeta);
|
||||||
|
|
||||||
|
// NIP-XX draft Place Photo event
|
||||||
|
const template = {
|
||||||
|
kind: 360,
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!template.created_at) {
|
||||||
|
template.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
this.toast.show('Photo published successfully');
|
||||||
|
this.status = '';
|
||||||
|
|
||||||
|
// 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 = '';
|
||||||
|
} finally {
|
||||||
|
this.isPublishing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-photo-upload">
|
||||||
|
<h2>Add Photo for {{this.title}}</h2>
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{this.error}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.status}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{this.status}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.file}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
<PlacePhotoUploadItem
|
||||||
|
@file={{this.file}}
|
||||||
|
@onSuccess={{this.handleUploadSuccess}}
|
||||||
|
@onRemove={{this.removeFile}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-publish"
|
||||||
|
disabled={{or (not this.allUploaded) this.isPublishing}}
|
||||||
|
{{on "click" this.publish}}
|
||||||
|
>
|
||||||
|
{{#if this.isPublishing}}
|
||||||
|
Publishing...
|
||||||
|
{{else}}
|
||||||
|
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 storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
createNewPlace() {
|
createNewPlace() {
|
||||||
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
|
|||||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasHeaderPhoto() {
|
||||||
|
return (
|
||||||
|
this.args.selectedPlace &&
|
||||||
|
this.nostrData.placePhotos &&
|
||||||
|
this.nostrData.placePhotos.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import { action } from '@ember/object';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import Modal from './modal';
|
||||||
|
import NostrConnect from './nostr-connect';
|
||||||
|
|
||||||
export default class UserMenuComponent extends Component {
|
export default class UserMenuComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osmAuth;
|
@service osmAuth;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connectRS() {
|
connectRS() {
|
||||||
@@ -30,6 +37,21 @@ export default class UserMenuComponent extends Component {
|
|||||||
this.osmAuth.logout();
|
this.osmAuth.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
disconnectNostr() {
|
||||||
|
this.nostrAuth.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-menu-popover">
|
<div class="user-menu-popover">
|
||||||
<ul class="account-list">
|
<ul class="account-list">
|
||||||
@@ -91,18 +113,43 @@ export default class UserMenuComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="account-item disabled">
|
<li class="account-item">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
<Icon @name="zap" @size={{18}} />
|
<Icon @name="zap" @size={{18}} />
|
||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.disconnectNostr}}
|
||||||
|
>Disconnect</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.openNostrConnectModal}}
|
||||||
|
>Connect</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
Coming soon
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<strong title={{this.nostrAuth.pubkey}}>
|
||||||
|
{{this.nostrData.userDisplayName}}
|
||||||
|
</strong>
|
||||||
|
{{else}}
|
||||||
|
Not connected
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.closeNostrConnectModal}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
17
app/modifiers/qr-code.js
Normal file
17
app/modifiers/qr-code.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
export default modifier((element, [text]) => {
|
||||||
|
if (text) {
|
||||||
|
QRCode.toCanvas(element, text, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to generate QR code', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
|
|||||||
async model(params) {
|
async model(params) {
|
||||||
const id = params.place_id;
|
const id = params.place_id;
|
||||||
|
|
||||||
|
let type, osmId;
|
||||||
|
let isExplicitOsm = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
id.startsWith('osm:node:') ||
|
id.startsWith('osm:node:') ||
|
||||||
id.startsWith('osm:way:') ||
|
id.startsWith('osm:way:') ||
|
||||||
id.startsWith('osm:relation:')
|
id.startsWith('osm:relation:')
|
||||||
) {
|
) {
|
||||||
const [, type, osmId] = id.split(':');
|
isExplicitOsm = true;
|
||||||
|
[, type, osmId] = id.split(':');
|
||||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
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();
|
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) {
|
if (bookmark) {
|
||||||
console.debug('Found in bookmarks:', bookmark.title);
|
console.debug('Found in bookmarks:', bookmark.title);
|
||||||
return bookmark;
|
return bookmark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isExplicitOsm) {
|
||||||
|
console.debug(
|
||||||
|
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
|
||||||
|
osmId
|
||||||
|
);
|
||||||
|
return await backgroundFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
console.warn('Not in bookmarks:', id);
|
console.warn('Not in bookmarks:', id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -119,14 +141,14 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialize(model) {
|
serialize(model) {
|
||||||
// If the model is a saved bookmark, use its ID
|
// If it's an OSM POI, use the explicit format first
|
||||||
if (model.id) {
|
|
||||||
return { place_id: model.id };
|
|
||||||
}
|
|
||||||
// If it's an OSM POI, use the explicit format
|
|
||||||
if (model.osmId && model.osmType) {
|
if (model.osmId && model.osmType) {
|
||||||
return { place_id: `osm:${model.osmType}:${model.osmId}` };
|
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
|
// Fallback
|
||||||
return { place_id: model.osmId };
|
return { place_id: model.osmId };
|
||||||
}
|
}
|
||||||
|
|||||||
205
app/services/blossom.js
Normal file
205
app/services/blossom.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
function bufferToHex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlossomUrl(serverUrl, path) {
|
||||||
|
let url = serverUrl || DEFAULT_BLOSSOM_SERVER;
|
||||||
|
if (url.endsWith('/')) {
|
||||||
|
url = url.slice(0, -1);
|
||||||
|
}
|
||||||
|
return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BlossomService extends Service {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
get servers() {
|
||||||
|
const servers = this.nostrData.blossomServers;
|
||||||
|
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||||
|
|
||||||
|
if (!this.settings.nostrPhotoFallbackUploads) {
|
||||||
|
return [allServers[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return allServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAuthHeader(action, hash, serverUrl) {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const serverHostname = new URL(serverUrl).hostname;
|
||||||
|
|
||||||
|
const authTemplate = {
|
||||||
|
kind: 24242,
|
||||||
|
created_at: now,
|
||||||
|
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||||
|
tags: [
|
||||||
|
['t', action],
|
||||||
|
['x', hash],
|
||||||
|
['expiration', String(now + 3600)],
|
||||||
|
['server', serverHostname],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const authEvent = await factory.sign(authTemplate);
|
||||||
|
const base64 = btoa(JSON.stringify(authEvent));
|
||||||
|
const base64url = base64
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
return `Nostr ${base64url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uploadToServer(file, hash, serverUrl) {
|
||||||
|
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
||||||
|
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
||||||
|
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'X-SHA-256': hash,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file, options = { sequential: false }) {
|
||||||
|
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
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);
|
||||||
|
|
||||||
|
const fallbackUrls = [];
|
||||||
|
let mainResult;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: mainResult.url,
|
||||||
|
fallbackUrls,
|
||||||
|
hash: payloadHash,
|
||||||
|
type: file.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _deleteFromServer(hash, serverUrl) {
|
||||||
|
const deleteUrl = getBlossomUrl(serverUrl, hash);
|
||||||
|
const authHeader = await this._getAuthHeader('delete', hash, serverUrl);
|
||||||
|
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(deleteUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || response.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(hash) {
|
||||||
|
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const servers = this.servers;
|
||||||
|
const mainServer = servers[0];
|
||||||
|
const fallbackServers = servers.slice(1);
|
||||||
|
|
||||||
|
const mainPromise = this._deleteFromServer(hash, mainServer);
|
||||||
|
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||||
|
this._deleteFromServer(hash, serverUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main server MUST succeed
|
||||||
|
await mainPromise;
|
||||||
|
|
||||||
|
// Fallback servers can fail, log warnings
|
||||||
|
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||||
|
for (let i = 0; i < fallbackResults.length; i++) {
|
||||||
|
const result = fallbackResults[i];
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.warn(
|
||||||
|
`Fallback delete from ${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';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
export default class MapUiService extends Service {
|
export default class MapUiService extends Service {
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
@tracked selectedPlace = null;
|
@tracked selectedPlace = null;
|
||||||
@tracked isSearching = false;
|
@tracked isSearching = false;
|
||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@@ -19,12 +21,14 @@ export default class MapUiService extends Service {
|
|||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
this.selectionOptions = options;
|
this.selectionOptions = options;
|
||||||
|
this.nostrData.loadPhotosForPlace(place);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.selectedPlace = null;
|
this.selectedPlace = null;
|
||||||
this.selectionOptions = {};
|
this.selectionOptions = {};
|
||||||
this.preventNextZoom = false;
|
this.preventNextZoom = false;
|
||||||
|
this.nostrData.loadPhotosForPlace(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchResults(results) {
|
setSearchResults(results) {
|
||||||
|
|||||||
295
app/services/nostr-auth.js
Normal file
295
app/services/nostr-auth.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import {
|
||||||
|
ExtensionSigner,
|
||||||
|
NostrConnectSigner,
|
||||||
|
PrivateKeySigner,
|
||||||
|
} from 'applesauce-signers';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marco:nostr_pubkey';
|
||||||
|
const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect'
|
||||||
|
const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
||||||
|
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||||
|
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||||
|
|
||||||
|
import { isMobile } from '../utils/device';
|
||||||
|
|
||||||
|
export default class NostrAuthService extends Service {
|
||||||
|
@service nostrRelay;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
|
@tracked pubkey = null;
|
||||||
|
@tracked signerType = null; // 'extension' or 'connect'
|
||||||
|
|
||||||
|
// Track NostrConnect state for the UI
|
||||||
|
@tracked connectStatus = null; // null | 'waiting' | 'connected'
|
||||||
|
@tracked connectUri = null; // For displaying a QR code if needed
|
||||||
|
|
||||||
|
_signerInstance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
// Enable debug logging for applesauce packages
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.debug = 'applesauce:*';
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const type = localStorage.getItem(STORAGE_KEY_TYPE);
|
||||||
|
if (saved) {
|
||||||
|
this.pubkey = saved;
|
||||||
|
this.signerType = type || 'extension';
|
||||||
|
this._verifyPubkey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _verifyPubkey() {
|
||||||
|
if (this.signerType === 'extension') {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
this.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const signer = new ExtensionSigner();
|
||||||
|
const extensionPubkey = await signer.getPublicKey();
|
||||||
|
if (extensionPubkey !== this.pubkey) {
|
||||||
|
this.pubkey = extensionPubkey;
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
}
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to verify extension nostr pubkey, logging out', e);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
} else if (this.signerType === 'connect') {
|
||||||
|
try {
|
||||||
|
await this._initConnectSigner();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to verify connect nostr pubkey, logging out', e);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile() {
|
||||||
|
return isMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return (
|
||||||
|
!!this.pubkey &&
|
||||||
|
(this.signerType === 'extension'
|
||||||
|
? typeof window.nostr !== 'undefined'
|
||||||
|
: true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
if (this._signerInstance) return this._signerInstance;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.signerType === 'extension' &&
|
||||||
|
typeof window.nostr !== 'undefined'
|
||||||
|
) {
|
||||||
|
return new ExtensionSigner();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.signerType === 'connect') {
|
||||||
|
// Must be initialized async due to the connect handshakes
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithExtension() {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._signerInstance = new ExtensionSigner();
|
||||||
|
this.pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
this.signerType = 'extension';
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get public key from extension:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getLocalSigner() {
|
||||||
|
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||||
|
let localSigner;
|
||||||
|
if (localKeyHex) {
|
||||||
|
localSigner = PrivateKeySigner.fromKey(localKeyHex);
|
||||||
|
} else {
|
||||||
|
localSigner = new PrivateKeySigner();
|
||||||
|
// Store the raw Uint8Array as hex string
|
||||||
|
localKeyHex = Array.from(localSigner.key)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
|
||||||
|
}
|
||||||
|
return localSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithApp() {
|
||||||
|
this.connectStatus = 'waiting';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localSigner = this._getLocalSigner();
|
||||||
|
|
||||||
|
// We use a specific relay for the connection handshake.
|
||||||
|
const relay = DEFAULT_CONNECT_RELAY;
|
||||||
|
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||||
|
|
||||||
|
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||||
|
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||||
|
|
||||||
|
this._signerInstance = new NostrConnectSigner({
|
||||||
|
pool: this.nostrRelay.pool,
|
||||||
|
relays: [relay],
|
||||||
|
signer: localSigner,
|
||||||
|
onAuth: async (url) => {
|
||||||
|
// NIP-46 auth callback. Normally the signer app does this natively via notification.
|
||||||
|
// But if it requires an explicit browser window:
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the uri for display (e.g., to redirect via intent)
|
||||||
|
this.connectUri = this._signerInstance.getNostrConnectURI({
|
||||||
|
name: 'Marco',
|
||||||
|
url: window.location.origin,
|
||||||
|
description: 'An unhosted maps application.',
|
||||||
|
icons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger the deep link intent immediately for the user if on mobile
|
||||||
|
if (this.isMobile) {
|
||||||
|
console.debug('Mobile detected, triggering deep link intent.');
|
||||||
|
window.location.href = this.connectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listening to the relay
|
||||||
|
console.debug('Opening signer connection to relay...');
|
||||||
|
await this._signerInstance.open();
|
||||||
|
console.debug('Signer connection opened successfully.');
|
||||||
|
|
||||||
|
// Wait for the remote signer to reply with their pubkey
|
||||||
|
console.debug('Waiting for remote signer to ack via relay...');
|
||||||
|
try {
|
||||||
|
await this._signerInstance.waitForSigner();
|
||||||
|
console.debug('Remote signer ack received!');
|
||||||
|
} catch (waitErr) {
|
||||||
|
console.error('Error while waiting for remote signer ack:', waitErr);
|
||||||
|
throw waitErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once connected, get the actual user pubkey
|
||||||
|
this.pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
this.signerType = 'connect';
|
||||||
|
this.connectStatus = 'connected';
|
||||||
|
|
||||||
|
// Save connection state
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
|
||||||
|
this._signerInstance.remote
|
||||||
|
);
|
||||||
|
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
this.connectStatus = null;
|
||||||
|
console.error('Failed to connect via Nostr Connect:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _initConnectSigner() {
|
||||||
|
const remotePubkey = localStorage.getItem(
|
||||||
|
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
|
||||||
|
);
|
||||||
|
const relay =
|
||||||
|
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY;
|
||||||
|
|
||||||
|
if (!remotePubkey) {
|
||||||
|
throw new Error('Missing Nostr Connect remote pubkey.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSigner = this._getLocalSigner();
|
||||||
|
|
||||||
|
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||||
|
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||||
|
|
||||||
|
this._signerInstance = new NostrConnectSigner({
|
||||||
|
pool: this.nostrRelay.pool,
|
||||||
|
relays: [relay],
|
||||||
|
signer: localSigner,
|
||||||
|
remote: remotePubkey,
|
||||||
|
onAuth: async (url) => {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this._signerInstance.open();
|
||||||
|
// Validate we can still get the pubkey from the remote signer
|
||||||
|
const pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
if (pubkey !== this.pubkey) {
|
||||||
|
throw new Error('Remote signer pubkey mismatch');
|
||||||
|
}
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event) {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error(
|
||||||
|
'Not connected or extension missing. Please connect Nostr again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.signer.signEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
this.pubkey = null;
|
||||||
|
this.nostrData?.loadProfile(null);
|
||||||
|
this.signerType = null;
|
||||||
|
this.connectStatus = null;
|
||||||
|
this.connectUri = null;
|
||||||
|
if (
|
||||||
|
this._signerInstance &&
|
||||||
|
typeof this._signerInstance.close === 'function'
|
||||||
|
) {
|
||||||
|
await this._signerInstance.close();
|
||||||
|
}
|
||||||
|
this._signerInstance = null;
|
||||||
|
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_TYPE);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
394
app/services/nostr-data.js
Normal file
394
app/services/nostr-data.js
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
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 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()
|
||||||
|
// which returns an Observable<NostrEvent>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.profile = null;
|
||||||
|
this.mailboxes = null;
|
||||||
|
this.blossomServers = [];
|
||||||
|
|
||||||
|
this._cleanupSubscriptions();
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
this.profile = profileContent;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._mailboxesSub = this.store
|
||||||
|
.model(MailboxesModel, pubkey)
|
||||||
|
.subscribe((mailboxesData) => {
|
||||||
|
this.mailboxes = mailboxesData;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._blossomSub = this.store
|
||||||
|
.replaceable(10063, pubkey)
|
||||||
|
.subscribe((event) => {
|
||||||
|
if (event && event.tags) {
|
||||||
|
this.blossomServers = event.tags
|
||||||
|
.filter((t) => t[0] === 'server' && t[1])
|
||||||
|
.map((t) => t[1]);
|
||||||
|
} else {
|
||||||
|
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() {
|
||||||
|
if (this.profile) {
|
||||||
|
if (this.profile.nip05) {
|
||||||
|
return this.profile.nip05;
|
||||||
|
}
|
||||||
|
if (this.profile.displayName || this.profile.display_name) {
|
||||||
|
return this.profile.displayName || this.profile.display_name;
|
||||||
|
}
|
||||||
|
if (this.profile.name) {
|
||||||
|
return this.profile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to npub
|
||||||
|
if (this.nostrAuth.pubkey) {
|
||||||
|
try {
|
||||||
|
const npub = npubEncode(this.nostrAuth.pubkey);
|
||||||
|
return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
|
||||||
|
} catch {
|
||||||
|
return this.nostrAuth.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Not connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupSubscriptions() {
|
||||||
|
if (this._requestSub) {
|
||||||
|
this._requestSub.unsubscribe();
|
||||||
|
this._requestSub = null;
|
||||||
|
}
|
||||||
|
if (this._profileSub) {
|
||||||
|
this._profileSub.unsubscribe();
|
||||||
|
this._profileSub = null;
|
||||||
|
}
|
||||||
|
if (this._mailboxesSub) {
|
||||||
|
this._mailboxesSub.unsubscribe();
|
||||||
|
this._mailboxesSub = null;
|
||||||
|
}
|
||||||
|
if (this._blossomSub) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/services/nostr-relay.js
Normal file
25
app/services/nostr-relay.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
|
export default class NostrRelayService extends Service {
|
||||||
|
pool = new RelayPool();
|
||||||
|
|
||||||
|
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(relays, event);
|
||||||
|
|
||||||
|
// Check if at least one relay accepted the event
|
||||||
|
const success = responses.some((res) => res.ok);
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export default class OsmService extends Service {
|
|||||||
controller = null;
|
controller = null;
|
||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
|
cachedPlaces = new Map();
|
||||||
|
|
||||||
cancelAll() {
|
cancelAll() {
|
||||||
if (this.controller) {
|
if (this.controller) {
|
||||||
@@ -232,6 +233,13 @@ out center;
|
|||||||
async fetchOsmObject(osmId, osmType) {
|
async fetchOsmObject(osmId, osmType) {
|
||||||
if (!osmId || !osmType) return null;
|
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;
|
let url;
|
||||||
if (osmType === 'node') {
|
if (osmType === 'node') {
|
||||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
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}`);
|
throw new Error(`OSM API request failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch OSM object:', e);
|
console.error('Failed to fetch OSM object:', e);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
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 {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
@tracked overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||||
@tracked mapKinetic = true;
|
@tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic;
|
||||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
@tracked photonApi = DEFAULT_SETTINGS.photonApi;
|
||||||
@tracked showQuickSearchButtons = true;
|
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||||
|
@tracked nostrPhotoFallbackUploads =
|
||||||
|
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||||
|
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||||
|
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -39,49 +53,83 @@ export default class SettingsService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSettings() {
|
loadSettings() {
|
||||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
let settings = {};
|
||||||
if (savedApi) {
|
const savedSettings = localStorage.getItem('marco:settings');
|
||||||
// Check if saved API is still in the allowed list
|
|
||||||
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
if (savedSettings) {
|
||||||
if (isValid) {
|
try {
|
||||||
this.overpassApi = savedApi;
|
settings = JSON.parse(savedSettings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse settings from localStorage', e);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If not valid, revert to default
|
// Migration from old individual keys
|
||||||
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||||
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
if (savedApi) settings.overpassApi = savedApi;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
if (savedKinetic !== null) {
|
if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true';
|
||||||
this.mapKinetic = savedKinetic === 'true';
|
|
||||||
}
|
|
||||||
// Default is true (initialized in class field)
|
|
||||||
|
|
||||||
const savedShowQuickSearch = localStorage.getItem(
|
const savedShowQuickSearch = localStorage.getItem(
|
||||||
'marco:show-quick-search'
|
'marco:show-quick-search'
|
||||||
);
|
);
|
||||||
if (savedShowQuickSearch !== null) {
|
if (savedShowQuickSearch !== null) {
|
||||||
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
settings.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverpassApi(url) {
|
const savedNostrPhotoFallbackUploads = localStorage.getItem(
|
||||||
this.overpassApi = url;
|
'marco:nostr-photo-fallback-uploads'
|
||||||
localStorage.setItem('marco:overpass-api', url);
|
);
|
||||||
|
if (savedNostrPhotoFallbackUploads !== null) {
|
||||||
|
settings.nostrPhotoFallbackUploads =
|
||||||
|
savedNostrPhotoFallbackUploads === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMapKinetic(enabled) {
|
const savedPhotonApi = localStorage.getItem('marco:photon-api');
|
||||||
this.mapKinetic = enabled;
|
if (savedPhotonApi) settings.photonApi = savedPhotonApi;
|
||||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateShowQuickSearchButtons(enabled) {
|
// Merge with defaults
|
||||||
this.showQuickSearchButtons = enabled;
|
const finalSettings = { ...DEFAULT_SETTINGS, ...settings };
|
||||||
localStorage.setItem('marco:show-quick-search', String(enabled));
|
|
||||||
|
// Validate overpass API
|
||||||
|
const isValid = this.overpassApis.some(
|
||||||
|
(api) => api.url === finalSettings.overpassApi
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePhotonApi(url) {
|
// Apply to tracked properties
|
||||||
this.photonApi = url;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(key, value) {
|
||||||
|
if (key in DEFAULT_SETTINGS) {
|
||||||
|
this[key] = value;
|
||||||
|
this.saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
--link-color-visited: #6a4fbf;
|
--link-color-visited: #6a4fbf;
|
||||||
--marker-color-primary: #ea4335;
|
--marker-color-primary: #ea4335;
|
||||||
--marker-color-dark: #b31412;
|
--marker-color-dark: #b31412;
|
||||||
|
--danger-color: var(--marker-color-primary);
|
||||||
|
--danger-color-dark: var(--marker-color-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -180,6 +182,9 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-placeholder {
|
.user-avatar-placeholder {
|
||||||
@@ -190,7 +195,146 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
background-color: rgb(255 255 255 / 2%);
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.is-dragging {
|
||||||
|
border-color: #61afef;
|
||||||
|
background-color: rgb(97 175 239 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #898989;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e262e;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .overlay,
|
||||||
|
.photo-upload-item .btn-remove-photo {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 60%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .error-overlay {
|
||||||
|
background: rgb(224 108 117 / 80%);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .btn-remove-photo {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgb(0 0 0 / 70%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .btn-remove-photo:hover {
|
||||||
|
background: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-publish {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Menu Popover */
|
/* User Menu Popover */
|
||||||
@@ -252,6 +396,9 @@ body {
|
|||||||
color: #898989;
|
color: #898989;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
margin-left: calc(18px + 0.75rem);
|
margin-left: calc(18px + 0.75rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-status strong {
|
.account-status strong {
|
||||||
@@ -328,6 +475,10 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header.no-border {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header h2 {
|
.sidebar-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -433,6 +584,64 @@ body {
|
|||||||
padding: 0 1.4rem 1rem;
|
padding: 0 1.4rem 1rem;
|
||||||
animation: details-slide-down 0.2s ease-out;
|
animation: details-slide-down 0.2s ease-out;
|
||||||
font-size: 0.9rem;
|
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 {
|
@keyframes details-slide-down {
|
||||||
@@ -463,7 +672,7 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
@@ -507,6 +716,11 @@ select.form-control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-section .form-group {
|
.settings-section .form-group {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section .form-group:first-of-type {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,12 +774,19 @@ select.form-control {
|
|||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a,
|
||||||
|
.meta-info .btn-link {
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
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;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +880,153 @@ abbr[title] {
|
|||||||
padding-bottom: 2rem;
|
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 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -835,6 +1203,7 @@ abbr[title] {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
margin: -6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo-icon svg {
|
.app-logo-icon svg {
|
||||||
@@ -1374,3 +1743,108 @@ button.create-place {
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nostr Connect */
|
||||||
|
.nostr-connect-modal h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-connect-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-connect-status {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container canvas {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white; /* Ensure good contrast for scanning */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 450px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-photo-upload h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: #eef;
|
||||||
|
color: #00c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group img {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
|
import camera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
|
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||||
|
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
@@ -25,8 +28,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw';
|
|||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
|
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||||
|
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
|
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||||
|
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
@@ -39,7 +46,6 @@ import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_fo
|
|||||||
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
|
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
|
||||||
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
|
||||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||||
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
@@ -128,8 +134,12 @@ const ICONS = {
|
|||||||
bus,
|
bus,
|
||||||
camera,
|
camera,
|
||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
|
'chevron-left': chevronLeft,
|
||||||
|
'chevron-right': chevronRight,
|
||||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
climbing_wall: climbingWall,
|
climbing_wall: climbingWall,
|
||||||
|
check,
|
||||||
|
'alert-circle': alertCircle,
|
||||||
'classical-building': classicalBuilding,
|
'classical-building': classicalBuilding,
|
||||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
@@ -214,6 +224,8 @@ const ICONS = {
|
|||||||
'tattoo-machine': tattooMachine,
|
'tattoo-machine': tattooMachine,
|
||||||
toolbox,
|
toolbox,
|
||||||
target,
|
target,
|
||||||
|
'trash-2': trash2,
|
||||||
|
'upload-cloud': uploadCloud,
|
||||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
'village-buildings': villageBuildings,
|
'village-buildings': villageBuildings,
|
||||||
@@ -235,7 +247,6 @@ const FILLED_ICONS = [
|
|||||||
'cup-and-saucer',
|
'cup-and-saucer',
|
||||||
'coffee-bean',
|
'coffee-bean',
|
||||||
'shopping-basket',
|
'shopping-basket',
|
||||||
'camera',
|
|
||||||
'person-sleeping-in-bed',
|
'person-sleeping-in-bed',
|
||||||
'loading-ring',
|
'loading-ring',
|
||||||
'nostrich',
|
'nostrich',
|
||||||
|
|||||||
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',
|
id: 'accommodation',
|
||||||
label: 'Hotels',
|
label: 'Hotels',
|
||||||
icon: 'person-sleeping-in-bed',
|
icon: 'person-sleeping-in-bed',
|
||||||
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||||
types: ['node', 'way', 'relation'],
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
111
doc/nostr/nip-place-photos.md
Normal file
111
doc/nostr/nip-place-photos.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# NIP-XX: Place Photos and Media
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
This NIP defines a standardized event format for sharing photos, videos, and other visual media tied to specific real-world locations (e.g., OpenStreetMap POIs).
|
||||||
|
|
||||||
|
While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP specifically targets map-based applications, travel logs, and location directories by mandating strict entity identifiers (`i` tags) and spatial indexing (`g` tags).
|
||||||
|
|
||||||
|
## Event Kind
|
||||||
|
|
||||||
|
`kind: 360`
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
This NIP relies on existing Nostr tag conventions to link media to places and provide inline metadata.
|
||||||
|
|
||||||
|
### Required Tags
|
||||||
|
|
||||||
|
#### 1. `i` — Entity Identifier
|
||||||
|
|
||||||
|
Identifies the exact place the media depicts using an external identifier (as defined in NIP-73). OpenStreetMap data is the default:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["i", "osm:node:123456"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
|
||||||
|
|
||||||
|
#### 2. `g` — Geohash
|
||||||
|
|
||||||
|
Used for spatial indexing and discovery. Events MUST include at least one high-precision geohash. To optimize for map-based discovery across different zoom levels, clients SHOULD include geohashes at multiple resolutions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["g", "thrr"] // coarse (~city)
|
||||||
|
["g", "thrrn5"] // medium (~1km)
|
||||||
|
["g", "thrrn5k"] // fine (~150m)
|
||||||
|
["g", "thrrn5kxyz"] // exact
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `imeta` — Inline Media Metadata
|
||||||
|
|
||||||
|
An event MUST contain exactly one `imeta` tag representing a single 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.
|
||||||
|
|
||||||
|
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
"url https://example.com/photo.jpg",
|
||||||
|
"m image/jpeg",
|
||||||
|
"dim 3024x4032",
|
||||||
|
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
||||||
|
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Tags
|
||||||
|
|
||||||
|
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
|
||||||
|
- `content-warning`: If the media contains NSFW or sensitive imagery.
|
||||||
|
- `published_at`: Unix timestamp of when the photo was originally taken or published.
|
||||||
|
|
||||||
|
## Example Event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<32-bytes hex>",
|
||||||
|
"pubkey": "<32-bytes hex>",
|
||||||
|
"created_at": 1713205000,
|
||||||
|
"kind": 360,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
["i", "osm:node:987654321"],
|
||||||
|
["g", "xn0m"],
|
||||||
|
["g", "xn0m7h"],
|
||||||
|
["g", "xn0m7hwq"],
|
||||||
|
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
"url https://example.com/ramen.jpg",
|
||||||
|
"m image/jpeg",
|
||||||
|
"dim 1080x1080",
|
||||||
|
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
||||||
|
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||||
|
],
|
||||||
|
|
||||||
|
["t", "ramen"],
|
||||||
|
["t", "food"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
313
doc/nostr/nip-place-reviews.md
Normal file
313
doc/nostr/nip-place-reviews.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# NIP-XX: Place Reviews
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
|
||||||
|
|
||||||
|
The design prioritizes:
|
||||||
|
|
||||||
|
- Small event size
|
||||||
|
- Interoperability across clients
|
||||||
|
- Flexibility for different place types
|
||||||
|
- Efficient geospatial querying using geohashes
|
||||||
|
|
||||||
|
## Event Kind
|
||||||
|
|
||||||
|
`kind: 30360`
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Additional tags MAY be included by clients but are not defined by this specification.
|
||||||
|
|
||||||
|
This NIP reuses and builds upon existing Nostr tag conventions:
|
||||||
|
|
||||||
|
- `i` tag: see NIP-73 (External Content Identifiers)
|
||||||
|
- `g` tag: geohash-based geotagging (community conventions)
|
||||||
|
|
||||||
|
Where conflicts arise, this NIP specifies the behavior for review events.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
#### `i` — Entity Identifier
|
||||||
|
|
||||||
|
Identifies the reviewed place using an external identifier. OpenStreetMap data is the default:
|
||||||
|
|
||||||
|
```
|
||||||
|
["i", "osm:<type>:<id>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
["i", "osm:node:123456"]
|
||||||
|
["i", "osm:way:987654"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geospatial Tags
|
||||||
|
|
||||||
|
#### `g` — Geohash
|
||||||
|
|
||||||
|
Geohash tags are used for spatial indexing and discovery.
|
||||||
|
|
||||||
|
##### Requirements
|
||||||
|
|
||||||
|
- Clients MUST include at least one high-precision geohash (length ≥ 9)
|
||||||
|
|
||||||
|
##### Recommendations
|
||||||
|
|
||||||
|
Clients SHOULD include geohashes at the following resolutions:
|
||||||
|
|
||||||
|
- length 4 — coarse (city-scale discovery)
|
||||||
|
- length 6 — medium (default query level, ~1 km)
|
||||||
|
- length 7 — fine (neighborhood, ~150 m)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
["g", "thrr"]
|
||||||
|
["g", "thrrn5"]
|
||||||
|
["g", "thrrn5k"]
|
||||||
|
["g", "thrrn5kxyz"]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Querying
|
||||||
|
|
||||||
|
Geospatial queries are performed using the `g` tag.
|
||||||
|
|
||||||
|
- Clients SHOULD query using a single geohash precision level per request
|
||||||
|
- Clients MAY include multiple geohash values in a filter to cover a bounding box
|
||||||
|
- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
|
||||||
|
- Clients MAY reduce precision or split queries when necessary
|
||||||
|
|
||||||
|
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
|
||||||
|
|
||||||
|
## Content (JSON)
|
||||||
|
|
||||||
|
The event `content` MUST be valid JSON matching the following schema.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "ratings"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"ratings": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["quality"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"quality": { "$ref": "#/$defs/score" },
|
||||||
|
"value": { "$ref": "#/$defs/score" },
|
||||||
|
"experience": { "$ref": "#/$defs/score" },
|
||||||
|
"accessibility": { "$ref": "#/$defs/score" },
|
||||||
|
"aspects": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"maxProperties": 20,
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/score" },
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{1,31}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommend": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"familiarity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"],
|
||||||
|
"description": "User familiarity: low = first visit; medium = occasional; high = frequent"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"visited_at": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1440
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
### Restaurant Review Event
|
||||||
|
|
||||||
|
#### Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
["i", "osm:node:123456"],
|
||||||
|
["g", "thrr"],
|
||||||
|
["g", "thrrn5"],
|
||||||
|
["g", "thrrn5k"],
|
||||||
|
["g", "thrrn5kxyz"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Content
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"ratings": {
|
||||||
|
"quality": 9,
|
||||||
|
"value": 8,
|
||||||
|
"experience": 9,
|
||||||
|
"accessibility": 7,
|
||||||
|
"aspects": {
|
||||||
|
"food": 9,
|
||||||
|
"service": 6,
|
||||||
|
"ambience": 8,
|
||||||
|
"wait_time": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommend": true,
|
||||||
|
"familiarity": "medium",
|
||||||
|
"context": {
|
||||||
|
"visited_at": 1713200000,
|
||||||
|
"duration_minutes": 90,
|
||||||
|
"party_size": 2
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"text": "Excellent food with bold flavors. Service was a bit slow, but the atmosphere made up for it.",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
### Ratings
|
||||||
|
|
||||||
|
- Scores are integers from 1 to 10
|
||||||
|
- `quality` is required and represents the core evaluation of the place
|
||||||
|
- Other fields are optional and context-dependent
|
||||||
|
|
||||||
|
### Aspects
|
||||||
|
|
||||||
|
- Free-form keys allow domain-specific ratings
|
||||||
|
- Clients MAY define and interpret aspect keys
|
||||||
|
- Clients SHOULD reuse commonly established aspect keys where possible
|
||||||
|
|
||||||
|
## Recommendation Signal
|
||||||
|
|
||||||
|
The `recommend` field represents a binary verdict:
|
||||||
|
|
||||||
|
- `true` → user recommends the place
|
||||||
|
- `false` → user does not recommend the place
|
||||||
|
|
||||||
|
Clients SHOULD strongly encourage users to provide this value.
|
||||||
|
|
||||||
|
## Familiarity
|
||||||
|
|
||||||
|
Represents user familiarity with the place:
|
||||||
|
|
||||||
|
- `low` → first visit or limited exposure
|
||||||
|
- `medium` → occasional visits
|
||||||
|
- `high` → frequent or expert-level familiarity
|
||||||
|
|
||||||
|
Clients MAY use this signal for weighting during aggregation.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Optional metadata about the visit.
|
||||||
|
|
||||||
|
- `visited_at` is a Unix timestamp
|
||||||
|
- `duration_minutes` represents time spent
|
||||||
|
- `party_size` indicates group size
|
||||||
|
|
||||||
|
## Interoperability
|
||||||
|
|
||||||
|
This specification defines a content payload only.
|
||||||
|
|
||||||
|
- In Nostr: place identity is conveyed via tags
|
||||||
|
- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
|
||||||
|
|
||||||
|
Content payloads SHOULD NOT include place identifiers.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### No Place Field in Content
|
||||||
|
|
||||||
|
Avoids duplication and inconsistency with tags.
|
||||||
|
|
||||||
|
### Multi-Aspect Ratings
|
||||||
|
|
||||||
|
Separates concerns (e.g. quality vs service), improving signal quality.
|
||||||
|
|
||||||
|
### Recommendation vs Score
|
||||||
|
|
||||||
|
Binary recommendation avoids averaging pitfalls and improves ranking.
|
||||||
|
|
||||||
|
### Familiarity
|
||||||
|
|
||||||
|
Provides a human-friendly proxy for confidence without requiring numeric input.
|
||||||
|
|
||||||
|
### Geohash Strategy
|
||||||
|
|
||||||
|
Multiple resolutions balance:
|
||||||
|
|
||||||
|
- efficient querying
|
||||||
|
- small event size
|
||||||
|
- early-stage discoverability
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
- Standardized aspect vocabularies
|
||||||
|
- Reputation and weighting models
|
||||||
|
- Indexing/aggregation services
|
||||||
|
- Cross-protocol mappings
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Clients SHOULD validate all input
|
||||||
|
- Malicious or spam reviews may require external moderation or reputation systems
|
||||||
7
doc/nostr/notes.md
Normal file
7
doc/nostr/notes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
|
||||||
|
- NIP 68/92/94 for place photos and image metadata (add "i" tag for "osm:node:123456" to NIP-68)
|
||||||
|
- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
|
||||||
|
- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
|
||||||
|
- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html
|
||||||
250
doc/nostr/ranking.md
Normal file
250
doc/nostr/ranking.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Ranking Algorithm
|
||||||
|
|
||||||
|
Your inputs:
|
||||||
|
|
||||||
|
- many users
|
||||||
|
- partial ratings
|
||||||
|
- different priorities
|
||||||
|
|
||||||
|
Your output:
|
||||||
|
|
||||||
|
> “Best place _for this user right now_”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Normalize scores
|
||||||
|
|
||||||
|
Convert 1–10 → 0–1:
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalized_score = (score - 1) / 9
|
||||||
|
```
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- easier math
|
||||||
|
- comparable across aspects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Per-aspect aggregation (avoid averages trap)
|
||||||
|
|
||||||
|
Instead of mean, compute:
|
||||||
|
|
||||||
|
### A. Positive ratio
|
||||||
|
|
||||||
|
```text
|
||||||
|
positive = score >= 7
|
||||||
|
negative = score <= 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
positive_ratio = positive_votes / total_votes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Confidence-weighted score
|
||||||
|
|
||||||
|
Use something like a **Wilson score interval** (this is key):
|
||||||
|
|
||||||
|
- prevents small-sample abuse
|
||||||
|
- avoids “1 review = #1 place”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Build aspect scores
|
||||||
|
|
||||||
|
For each aspect:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aspect_score = f(
|
||||||
|
positive_ratio,
|
||||||
|
confidence,
|
||||||
|
number_of_reviews
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can approximate with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aspect_score = positive_ratio * log(1 + review_count)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Simple, works surprisingly well)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: User preference weighting
|
||||||
|
|
||||||
|
User defines:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quality": 0.5,
|
||||||
|
"value": 0.2,
|
||||||
|
"service": 0.2,
|
||||||
|
"speed": 0.1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
final_score = Σ (aspect_score × weight)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Context filtering (this is your unfair advantage)
|
||||||
|
|
||||||
|
Filter reviews before scoring:
|
||||||
|
|
||||||
|
- time-based:
|
||||||
|
- “last 6 months”
|
||||||
|
|
||||||
|
- context-based:
|
||||||
|
- lunch vs dinner
|
||||||
|
- solo vs group
|
||||||
|
|
||||||
|
This is something centralized platforms barely do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Reviewer weighting (later, but powerful)
|
||||||
|
|
||||||
|
Weight reviews by:
|
||||||
|
|
||||||
|
- consistency
|
||||||
|
- similarity to user preferences
|
||||||
|
- past agreement
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
|
||||||
|
> “people like you liked this”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Example end-to-end
|
||||||
|
|
||||||
|
### Raw reviews:
|
||||||
|
|
||||||
|
| User | Food | Service |
|
||||||
|
| ---- | ---- | ------- |
|
||||||
|
| A | 9 | 4 |
|
||||||
|
| B | 8 | 5 |
|
||||||
|
| C | 10 | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Derived:
|
||||||
|
|
||||||
|
- food → high positive ratio (~100%)
|
||||||
|
- service → low (~33%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User preferences:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"food": 0.8,
|
||||||
|
"service": 0.2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ ranks high
|
||||||
|
|
||||||
|
Another user:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"food": 0.3,
|
||||||
|
"service": 0.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ ranks low
|
||||||
|
|
||||||
|
👉 Same data, different truth
|
||||||
|
That’s your killer feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Critical design choices (don’t skip these)
|
||||||
|
|
||||||
|
## A. No global score in protocol
|
||||||
|
|
||||||
|
Let clients compute it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Embrace incomplete data
|
||||||
|
|
||||||
|
Most reviews will have:
|
||||||
|
|
||||||
|
- 1–3 aspects only
|
||||||
|
|
||||||
|
That’s fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Time decay (important)
|
||||||
|
|
||||||
|
Recent reviews should matter more:
|
||||||
|
|
||||||
|
```text
|
||||||
|
weight = e^(-λ × age)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Anti-gaming baseline
|
||||||
|
|
||||||
|
Even in nostr:
|
||||||
|
|
||||||
|
- spam will happen
|
||||||
|
|
||||||
|
Mitigation later:
|
||||||
|
|
||||||
|
- require minimum interactions
|
||||||
|
- reputation layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. What you’ve built (zooming out)
|
||||||
|
|
||||||
|
This is not a review system.
|
||||||
|
|
||||||
|
It’s:
|
||||||
|
|
||||||
|
> A decentralized, multi-dimensional reputation graph for real-world places
|
||||||
|
|
||||||
|
That’s much bigger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Next step (if you want to go deeper)
|
||||||
|
|
||||||
|
We can design:
|
||||||
|
|
||||||
|
### A. Query layer
|
||||||
|
|
||||||
|
- how clients fetch & merge nostr reviews efficiently
|
||||||
|
|
||||||
|
### B. Anti-spam / trust model
|
||||||
|
|
||||||
|
- web-of-trust
|
||||||
|
- staking / reputation
|
||||||
|
|
||||||
|
### C. OSM integration details
|
||||||
|
|
||||||
|
- handling duplicates
|
||||||
|
- POI identity conflicts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If I had to pick one next:
|
||||||
|
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.
|
||||||
100
doc/nostr/ratings.md
Normal file
100
doc/nostr/ratings.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Canonical Aspect Vocabulary (v0.1)
|
||||||
|
|
||||||
|
## A. Core universal aspects
|
||||||
|
|
||||||
|
These should work for _any_ place:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"quality", // core offering (food, repair, exhibits, etc.)
|
||||||
|
"value", // value for money/time
|
||||||
|
"experience", // comfort, usability, vibe
|
||||||
|
"accessibility" // ease of access, inclusivity
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why these work
|
||||||
|
|
||||||
|
- **quality** → your “product” abstraction (critical)
|
||||||
|
- **value** → universally meaningful signal
|
||||||
|
- **experience** → captures everything “soft”
|
||||||
|
- **accessibility** → often ignored but high utility
|
||||||
|
|
||||||
|
👉 Resist adding more. Every extra “universal” weakens the concept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Common cross-domain aspects (recommended pool)
|
||||||
|
|
||||||
|
Not universal, but widely reusable:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"service", // human interaction
|
||||||
|
"speed", // waiting time / turnaround
|
||||||
|
"cleanliness",
|
||||||
|
"safety",
|
||||||
|
"reliability",
|
||||||
|
"atmosphere"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
These apply to:
|
||||||
|
|
||||||
|
- restaurants, garages, clinics, parks, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Domain-specific examples (NOT standardized)
|
||||||
|
|
||||||
|
Let clients define freely:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"restaurant": ["food", "drinks"],
|
||||||
|
"bar": ["drinks", "music"],
|
||||||
|
"garage": ["work_quality", "honesty"],
|
||||||
|
"park": ["greenery", "amenities"],
|
||||||
|
"museum": ["exhibits", "crowding"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Key rule (this prevents chaos)
|
||||||
|
|
||||||
|
👉 **Aspect keys MUST be lowercase snake_case**
|
||||||
|
|
||||||
|
👉 **Meaning is defined socially, not technically**
|
||||||
|
|
||||||
|
To reduce fragmentation:
|
||||||
|
|
||||||
|
- publish a **public registry (GitHub repo)**
|
||||||
|
- clients can:
|
||||||
|
- suggest additions
|
||||||
|
- map synonyms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Optional normalization hint (important later)
|
||||||
|
|
||||||
|
Allow this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"aspect_aliases": {
|
||||||
|
"food": "quality",
|
||||||
|
"work_quality": "quality"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Not required, but useful for aggregation engines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Map familiarity in UI to:
|
||||||
|
|
||||||
|
- high: “I know this place well”
|
||||||
|
- medium: “Been a few times”
|
||||||
|
- low: “First visit”
|
||||||
92
doc/nostr/review-schema.json
Normal file
92
doc/nostr/review-schema.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://example.org/schemas/place-review.json",
|
||||||
|
"title": "Decentralized Place Review (Nostr/Event Content)",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "place", "ratings"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"ratings": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["quality"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"quality": { "$ref": "#/$defs/score" },
|
||||||
|
"value": { "$ref": "#/$defs/score" },
|
||||||
|
"experience": { "$ref": "#/$defs/score" },
|
||||||
|
"accessibility": { "$ref": "#/$defs/score" },
|
||||||
|
|
||||||
|
"aspects": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"maxProperties": 20,
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/score" },
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{1,31}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"recommend": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the user recommends this place to others"
|
||||||
|
},
|
||||||
|
|
||||||
|
"familiarity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"],
|
||||||
|
"description": "User familiarity with the place. Suggested interpretation: 'low' = first visit or very limited experience; 'medium' = visited a few times or moderate familiarity; 'high' = frequent visitor or strong familiarity."
|
||||||
|
},
|
||||||
|
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"visited_at": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1440
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"review": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"$defs": {
|
||||||
|
"score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -102,9 +102,18 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
"@waysidemapping/pinhead": "^15.20.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-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.0.0",
|
||||||
"oauth2-pkce": "^2.1.3"
|
"nostr-idb": "^5.0.0",
|
||||||
|
"oauth2-pkce": "^2.1.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
954
pnpm-lock.yaml
generated
954
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
|||||||
setupRenderingTest as upstreamSetupRenderingTest,
|
setupRenderingTest as upstreamSetupRenderingTest,
|
||||||
setupTest as upstreamSetupTest,
|
setupTest as upstreamSetupTest,
|
||||||
} from 'ember-qunit';
|
} from 'ember-qunit';
|
||||||
|
import { setupNostrMocks } from './mock-nostr';
|
||||||
|
|
||||||
// This file exists to provide wrappers around ember-qunit's
|
// This file exists to provide wrappers around ember-qunit's
|
||||||
// test setup functions. This way, you can easily extend the setup that is
|
// test setup functions. This way, you can easily extend the setup that is
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
|
|
||||||
function setupApplicationTest(hooks, options) {
|
function setupApplicationTest(hooks, options) {
|
||||||
upstreamSetupApplicationTest(hooks, options);
|
upstreamSetupApplicationTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for application tests can be done here.
|
// Additional setup for application tests can be done here.
|
||||||
//
|
//
|
||||||
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
|
|||||||
|
|
||||||
function setupRenderingTest(hooks, options) {
|
function setupRenderingTest(hooks, options) {
|
||||||
upstreamSetupRenderingTest(hooks, options);
|
upstreamSetupRenderingTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for rendering tests can be done here.
|
// Additional setup for rendering tests can be done here.
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTest(hooks, options) {
|
function setupTest(hooks, options) {
|
||||||
upstreamSetupTest(hooks, options);
|
upstreamSetupTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for unit tests can be done here.
|
// 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