Merge pull request 'Connect Nostr, integrate place photos' (#45) from feature/nostr_place_reviews into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
2026-04-22 09:31:59 +00:00
44 changed files with 5428 additions and 163 deletions

View File

@@ -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}}

View File

@@ -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>

View 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>
}

View 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>
}

View 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>
}

View 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>
}

View File

@@ -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
View 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>
}

View 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>
}

View File

@@ -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>
} }

View 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>
}

View 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>
}

View 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>
}

View File

@@ -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"

View File

@@ -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>
} }

View 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;
}
};
});

View 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
View 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);
});
}
});

View File

@@ -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
View 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
);
}
}
}
}

View 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();
}
}

View File

@@ -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
View 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
View 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();
}
}
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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();
}
} }
} }

View File

@@ -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
View File

@@ -0,0 +1,4 @@
export function isMobile() {
if (typeof navigator === 'undefined') return false;
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
}

View File

@@ -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
View 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;
}

View File

@@ -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'],
}, },
]; ];

View 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,
});
}
};

View 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.

View 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
View 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
View 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 110 → 01:
```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
Thats your killer feature.
---
# 4. Critical design choices (dont skip these)
## A. No global score in protocol
Let clients compute it.
---
## B. Embrace incomplete data
Most reviews will have:
- 13 aspects only
Thats 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 youve built (zooming out)
This is not a review system.
Its:
> A decentralized, multi-dimensional reputation graph for real-world places
Thats 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
View 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”

View 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
}
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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.
} }

View 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);
});
}

View 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');
});
});