Compare commits

..

43 Commits

Author SHA1 Message Date
2943125dbd 1.20.4
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-25 10:55:20 +01:00
a32ad7572b Add more marker icons
Some checks failed
CI / Lint (push) Successful in 32s
CI / Test (push) Has been cancelled
2026-04-25 10:53:53 +01:00
a1b3957c83 Fix photo icon in map markers 2026-04-25 10:42:05 +01:00
9f2f233c22 Adjust JPEG quality for large photos 2026-04-25 10:41:39 +01:00
1ba4afdf08 1.20.3
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-24 13:55:07 +01:00
d764134513 Remove superfluous publishing status alert 2026-04-24 13:53:40 +01:00
e38f540c79 1.20.2
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-24 12:28:08 +01:00
73ad5b4eb1 Disable closing modal during photo upload 2026-04-24 12:24:19 +01:00
b4a70233cf Show detailed photo upload status
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-24 11:56:37 +01:00
cb4b9c6b40 Render portrait thumbnails as squares on mobile
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
A bit too small otherwise
2026-04-24 11:01:58 +01:00
98dcb4f25b 1.20.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 57s
2026-04-23 09:23:41 +01:00
7709634a9a Merge pull request 'Clear Nostr event cache from Settings' (#47) from feature/clear_nostr_cache into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
Reviewed-on: #47
2026-04-23 08:22:02 +00:00
3ddc85669f Clear nostr event cache from Settings
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-23 09:19:25 +01:00
95961e680f Add rationale for kind numbers
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
2026-04-22 15:37:45 +04:00
9468a6a0cc Revise photos NIP draft
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-22 15:31:24 +04:00
c9465c8fa8 1.20.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-22 13:33:55 +04:00
6c5c1fea27 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
2026-04-22 09:31:59 +00:00
fe41369754 Reset scroll position when switching between places
All checks were successful
CI / Lint (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 1m3s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-22 13:10:11 +04:00
1498c5a713 Improve dropzone size 2026-04-22 13:02:12 +04:00
b6e2964f8e Show placeholder on mobile when photos not filling space 2026-04-22 12:40:45 +04:00
d1d179bb93 Lazy-load place photos
Only preload photos in view as well as the next one(s), not all of them
2026-04-22 12:02:44 +04:00
b83a16bf13 Use button element for add-photo link 2026-04-22 11:32:57 +04:00
c853418fbb Fix auto-scroll to new photo on mobile 2026-04-22 11:32:37 +04:00
4fed8c05c5 Change routing to always use OSM IDs except for custom places
Also implements a short term cache for OSM place data, so we can load it
multiple times without multiplying network requests where needed
2026-04-22 11:01:32 +04:00
670128cbda Immediately render newly uploaded photo and scroll to it 2026-04-22 10:38:06 +04:00
d8fa30c74b Revert to single photo per upload and event
See NIP changes for reasoning. It also keeps the UI a bit cleaner and
we don't have to queue processing on mobile for mass uploads.
2026-04-22 10:18:47 +04:00
0f8d7046ac Improve blurhash decode warning, use valid hashes in tests 2026-04-22 09:19:31 +04:00
8ca7481a79 Mock nostr service globally in tests 2026-04-22 09:03:38 +04:00
cd25c55bd7 Smaller gap between photos in carousel on mobile 2026-04-22 08:44:17 +04:00
32c4f7da57 Add integration tests for carousel 2026-04-22 08:34:03 +04:00
71939a30c3 Fix carousel chevron links
Correct color, hide while disabled
2026-04-22 08:30:55 +04:00
7285ace882 Move parsing of place photos to util
Can be used in gallery later
2026-04-22 07:40:18 +04:00
94ba33ecc1 Render all place photos in a carousel 2026-04-22 07:32:55 +04:00
85a8699b78 Render header photo in place details
Shows the blurhash and fades in the image once downloaded
2026-04-21 23:07:06 +04:00
99cfd96ca1 Fetch and cache photo events while browsing map and when opening place details 2026-04-21 21:28:57 +04:00
8d40b3bb35 Add and use relay list settings 2026-04-21 19:16:52 +04:00
c5316bf336 Refactor settings, DRY up everything 2026-04-21 15:59:55 +04:00
a384e83dd0 Cache own Nostr avatar image 2026-04-21 15:17:04 +04:00
b23d54d74f Cache user profile/settings events in IndexedDB 2026-04-21 14:58:05 +04:00
5bd4dba907 Refactor settings menu, add Nostr settings
Adds a setting to control if photos should be uploaded only to the
main/default server, or all known servers of a user.

Only upload to the main server by default, to speed up adding photos.
2026-04-21 14:39:38 +04:00
54ba99673f Break up settings into sub sections 2026-04-21 14:17:14 +04:00
54445f249b Fix app menu header height
Wasn't the same between main menu and sub menus
2026-04-21 14:16:42 +04:00
9828ad2714 Close photo upload window, show toast when published 2026-04-21 14:16:05 +04:00
42 changed files with 2078 additions and 335 deletions

View File

@@ -8,6 +8,7 @@ import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
import { and } from 'ember-truth-helpers';
import cachedImage from '../modifiers/cached-image';
export default class AppHeaderComponent extends Component {
@service storage;
@@ -71,7 +72,7 @@ export default class AppHeaderComponent extends Component {
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
}}
<img
src={{this.nostrData.profile.picture}}
{{cachedImage this.nostrData.profile.picture}}
class="user-avatar"
alt="User Avatar"
/>

View File

@@ -1,31 +1,22 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuSettingsMapUi from './settings/map-ui';
import AppMenuSettingsApis from './settings/apis';
import AppMenuSettingsNostr from './settings/nostr';
export default class AppMenuSettings extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
updateSetting(key, event) {
let value = event.target.value;
if (value === 'true') value = true;
if (value === 'false') value = false;
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
this.settings.update(key, value);
}
<template>
@@ -41,88 +32,9 @@ export default class AppMenuSettings extends Component {
<div class="sidebar-content">
<section class="settings-section">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
</section>
</div>
</template>

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,242 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { normalizeRelayUrl } from '../../../utils/nostr';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
export default class AppMenuSettingsNostr extends Component {
@service settings;
@service nostrData;
@service toast;
@tracked newReadRelay = '';
@tracked newWriteRelay = '';
@action
updateNewReadRelay(event) {
this.newReadRelay = event.target.value;
}
@action
updateNewWriteRelay(event) {
this.newWriteRelay = event.target.value;
}
@action
addReadRelay() {
const url = normalizeRelayUrl(this.newReadRelay);
if (!url) return;
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const set = new Set([...current, url]);
this.settings.update('nostrReadRelays', Array.from(set));
this.newReadRelay = '';
}
@action
removeReadRelay(url) {
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrReadRelays', filtered);
}
@action
handleReadRelayKeydown(event) {
if (event.key === 'Enter') {
this.addReadRelay();
}
}
@action
handleWriteRelayKeydown(event) {
if (event.key === 'Enter') {
this.addWriteRelay();
}
}
@action
resetReadRelays() {
this.settings.update('nostrReadRelays', null);
}
@action
addWriteRelay() {
const url = normalizeRelayUrl(this.newWriteRelay);
if (!url) return;
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const set = new Set([...current, url]);
this.settings.update('nostrWriteRelays', Array.from(set));
this.newWriteRelay = '';
}
@action
removeWriteRelay(url) {
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrWriteRelays', filtered);
}
@action
resetWriteRelays() {
this.settings.update('nostrWriteRelays', null);
}
@action
async clearCache() {
try {
await this.nostrData.clearCache();
this.toast.show('Nostr cache cleared');
} catch (e) {
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
}
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="zap" @size={{20}} />
<span>Nostr</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeReadRelays as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
</li>
{{/each}}
</ul>
<div class="add-relay-input">
<input
id="new-read-relay"
type="text"
class="form-control"
placeholder="relay.example.com"
value={{this.newReadRelay}}
{{on "input" this.updateNewReadRelay}}
{{on "keydown" this.handleReadRelayKeydown}}
/>
<button
type="button"
class="btn btn-secondary"
{{on "click" this.addReadRelay}}
>Add</button>
</div>
{{#if this.settings.nostrReadRelays}}
<button
type="button"
class="btn-link reset-relays"
{{on "click" this.resetReadRelays}}
>
Reset to Defaults
</button>
{{/if}}
</div>
<div class="form-group">
<label for="new-write-relay">Write Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeWriteRelays as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
</li>
{{/each}}
</ul>
<div class="add-relay-input">
<input
id="new-write-relay"
type="text"
class="form-control"
placeholder="relay.example.com"
value={{this.newWriteRelay}}
{{on "input" this.updateNewWriteRelay}}
{{on "keydown" this.handleWriteRelayKeydown}}
/>
<button
type="button"
class="btn btn-secondary"
{{on "click" this.addWriteRelay}}
>Add</button>
</div>
{{#if this.settings.nostrWriteRelays}}
<button
type="button"
class="btn-link reset-relays"
{{on "click" this.resetWriteRelays}}
>
Reset to Defaults
</button>
{{/if}}
</div>
<div class="form-group">
<label>Cached data</label>
<button
type="button"
class="btn btn-outline btn-full"
{{on "click" this.clearCache}}
>
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
Clear profiles, photos, and reviews
</button>
</div>
</div>
</details>
</template>
}

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 router;
@service settings;
@service nostrData;
mapInstance;
bookmarkSource;
@@ -1033,7 +1034,7 @@ export default class MapComponent extends Component {
}
handleMapMove = async () => {
if (!this.mapInstance) return;
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
const view = this.mapInstance.getView();
const center = toLonLat(view.getCenter());
@@ -1078,6 +1079,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox);
this.nostrData.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView);
// Persist view to localStorage

View File

@@ -11,6 +11,7 @@ export default class Modal extends Component {
@action
close() {
if (this.args.disableClose) return;
if (this.args.onClose) {
this.args.onClose();
}
@@ -31,10 +32,11 @@ export default class Modal extends Component {
>
<button
type="button"
class="close-modal-btn btn-text"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} />
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>

View File

@@ -6,12 +6,14 @@ import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { mapToStorageSchema } from '../utils/place-mapping';
import { getSocialInfo } from '../utils/social-links';
import { parsePlacePhotos } from '../utils/nostr';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
import PlacePhotosCarousel from './place-photos-carousel';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -19,10 +21,18 @@ import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@service nostrData;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false;
@action
handleUploadStateChange(isActive) {
this.isPhotoUploadActive = isActive;
}
@action
openPhotoUploadModal(e) {
@@ -37,8 +47,20 @@ export default class PlaceDetails extends Component {
}
@action
closePhotoUploadModal() {
closePhotoUploadModal(eventId) {
if (this.isPhotoUploadActive) return;
this.isPhotoUploadModalOpen = false;
if (typeof eventId === 'string') {
this.newlyUploadedPhotoId = eventId;
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
setTimeout(() => {
const sidebar = document.querySelector('.sidebar-content');
if (sidebar) {
sidebar.scrollTop = 0;
}
}, 50);
}
}
@action
@@ -76,6 +98,16 @@ export default class PlaceDetails extends Component {
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
get photos() {
const rawPhotos = this.nostrData.placePhotos;
const parsedPhotos = parsePlacePhotos(rawPhotos);
return parsedPhotos.map((photo) => ({
...photo,
style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`),
}));
}
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
@@ -339,6 +371,12 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}}
/>
{{else}}
<PlacePhotosCarousel
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
/>
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
@@ -538,11 +576,15 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}}
<div class="meta-info">
<p class="content-with-icon">
<Icon @name="camera" />
<Icon @name="feather-camera" />
<span>
<a href="#" {{on "click" this.openPhotoUploadModal}}>
<button
type="button"
class="btn-link"
{{on "click" this.openPhotoUploadModal}}
>
Add a photo
</a>
</button>
</span>
</p>
</div>
@@ -550,8 +592,15 @@ export default class PlaceDetails extends Component {
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<PlacePhotoUpload @place={{this.saveablePlace}} />
<Modal
@onClose={{this.closePhotoUploadModal}}
@disableClose={{this.isPhotoUploadActive}}
>
<PlacePhotoUpload
@place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}}
@onUploadStateChange={{this.handleUploadStateChange}}
/>
</Modal>
{{/if}}

View File

@@ -7,9 +7,10 @@ import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { isMobile } from '../utils/device';
import Blurhash from './blurhash';
const MAX_IMAGE_DIMENSION = 1920;
const IMAGE_QUALITY = 0.94;
const IMAGE_QUALITY = 0.9;
const MAX_THUMBNAIL_DIMENSION = 350;
const THUMBNAIL_QUALITY = 0.9;
@@ -19,7 +20,9 @@ export default class PlacePhotoUploadItem extends Component {
@service toast;
@tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = '';
@tracked statusText = '';
constructor() {
super(...arguments);
@@ -45,6 +48,7 @@ export default class PlacePhotoUploadItem extends Component {
uploadTask = task(async (file) => {
this.error = '';
this.statusText = 'Processing';
try {
// 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process(
@@ -54,6 +58,8 @@ export default class PlacePhotoUploadItem extends Component {
true // computeBlurhash
);
this.blurhash = mainData.blurhash;
// 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process(
file,
@@ -67,18 +73,34 @@ export default class PlacePhotoUploadItem extends Component {
let mainResult, thumbResult;
const isMobileDevice = isMobile();
const mainProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing photo upload';
if (status === 'uploading') this.statusText = 'Uploading photo';
};
const thumbProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing thumbnail upload';
if (status === 'uploading') this.statusText = 'Uploading thumbnail';
};
if (isMobileDevice) {
// Mobile: sequential uploads to preserve bandwidth and memory
mainResult = await this.blossom.upload(mainData.blob, {
sequential: true,
onProgress: mainProgress,
});
thumbResult = await this.blossom.upload(thumbData.blob, {
sequential: true,
onProgress: thumbProgress,
});
} else {
// Desktop: concurrent uploads
const mainUploadPromise = this.blossom.upload(mainData.blob);
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
const mainUploadPromise = this.blossom.upload(mainData.blob, {
onProgress: mainProgress,
});
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
onProgress: thumbProgress,
});
[mainResult, thumbResult] = await Promise.all([
mainUploadPromise,
@@ -110,6 +132,9 @@ export default class PlacePhotoUploadItem extends Component {
{{if this.uploadTask.isRunning 'is-uploading'}}
{{if this.error 'has-error'}}"
>
{{#if this.blurhash}}
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
{{/if}}
<img src={{this.thumbnailUrl}} alt="thumbnail" />
{{#if this.uploadTask.isRunning}}
@@ -120,6 +145,9 @@ export default class PlacePhotoUploadItem extends Component {
@color="white"
class="spin-animation"
/>
{{#if this.statusText}}
<span class="upload-status-text">{{this.statusText}}</span>
{{/if}}
</div>
{{/if}}

View File

@@ -13,12 +13,12 @@ import { or, not } from 'ember-truth-helpers';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@service nostrData;
@service blossom;
@service toast;
@tracked files = [];
@tracked uploadedPhotos = [];
@tracked status = '';
@tracked file = null;
@tracked uploadedPhoto = null;
@tracked error = '';
@tracked isPublishing = false;
@tracked isDragging = false;
@@ -33,17 +33,13 @@ export default class PlacePhotoUpload extends Component {
get allUploaded() {
return (
this.files.length > 0 && this.files.length === this.uploadedPhotos.length
this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
);
}
get photoWord() {
return this.files.length === 1 ? 'Photo' : 'Photos';
}
@action
handleFileSelect(event) {
this.addFiles(event.target.files);
this.addFile(event.target.files[0]);
event.target.value = ''; // Reset input
}
@@ -63,32 +59,42 @@ export default class PlacePhotoUpload extends Component {
handleDrop(event) {
event.preventDefault();
this.isDragging = false;
this.addFiles(event.dataTransfer.files);
if (event.dataTransfer.files.length > 0) {
this.addFile(event.dataTransfer.files[0]);
}
}
addFiles(fileList) {
if (!fileList) return;
const newFiles = Array.from(fileList).filter((f) =>
f.type.startsWith('image/')
);
this.files = [...this.files, ...newFiles];
addFile(file) {
if (!file || !file.type.startsWith('image/')) {
this.error = 'Please select a valid image file.';
return;
}
this.error = '';
// If a photo was already uploaded but not published, delete it from the server
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = file;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(true);
}
}
@action
handleUploadSuccess(photoData) {
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
this.uploadedPhoto = photoData;
}
@action
removeFile(fileToRemove) {
const photoData = this.uploadedPhotos.find((p) => p.file === fileToRemove);
this.files = this.files.filter((f) => f !== fileToRemove);
this.uploadedPhotos = this.uploadedPhotos.filter(
(p) => p.file !== fileToRemove
);
if (photoData && photoData.hash && photoData.url) {
this.deletePhotoTask.perform(photoData);
removeFile() {
if (this.uploadedPhoto) {
this.deletePhotoTask.perform(this.uploadedPhoto);
}
this.file = null;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
}
@@ -125,7 +131,6 @@ export default class PlacePhotoUpload extends Component {
return;
}
this.status = 'Publishing event...';
this.error = '';
this.isPublishing = true;
@@ -141,34 +146,33 @@ export default class PlacePhotoUpload extends Component {
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
for (const photo of this.uploadedPhotos) {
const imeta = ['imeta', `url ${photo.url}`];
const photo = this.uploadedPhoto;
const imeta = ['imeta', `url ${photo.url}`];
imeta.push(`m ${photo.type}`);
imeta.push(`m ${photo.type}`);
if (photo.dim) {
imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`fallback ${fallbackUrl}`);
}
}
if (photo.thumbUrl) {
imeta.push(`thumb ${photo.thumbUrl}`);
}
if (photo.blurhash) {
imeta.push(`blurhash ${photo.blurhash}`);
}
tags.push(imeta);
if (photo.dim) {
imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
imeta.push(`fallback ${fallbackUrl}`);
}
}
if (photo.thumbUrl) {
imeta.push(`thumb ${photo.thumbUrl}`);
}
if (photo.blurhash) {
imeta.push(`blurhash ${photo.blurhash}`);
}
tags.push(imeta);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
@@ -181,16 +185,24 @@ export default class PlacePhotoUpload extends Component {
}
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
this.nostrData.store.add(event);
this.status = 'Published successfully!';
this.toast.show('Photo published successfully');
// Clear out the files so user can upload more or be done
this.files = [];
this.uploadedPhotos = [];
// Clear out the file so user can upload more or be done
this.file = null;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
if (this.args.onClose) {
this.args.onClose(event.id);
}
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
} finally {
this.isPublishing = false;
}
@@ -198,7 +210,7 @@ export default class PlacePhotoUpload extends Component {
<template>
<div class="place-photo-upload">
<h2>Add Photos for {{this.title}}</h2>
<h2>Add Photo for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
@@ -206,42 +218,13 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
<div
class="dropzone {{if this.isDragging 'is-dragging'}}"
{{on "dragover" this.handleDragOver}}
{{on "dragleave" this.handleDragLeave}}
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop photos here, or click to browse</p>
</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
multiple
class="file-input-hidden"
disabled={{this.isPublishing}}
{{on "change" this.handleFileSelect}}
/>
</div>
{{#if this.files.length}}
{{#if this.file}}
<div class="photo-grid">
{{#each this.files as |file|}}
<PlacePhotoUploadItem
@file={{file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
{{/each}}
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
</div>
<button
@@ -253,11 +236,29 @@ export default class PlacePhotoUpload extends Component {
{{#if this.isPublishing}}
Publishing...
{{else}}
Publish
{{this.files.length}}
{{this.photoWord}}
Publish Photo
{{/if}}
</button>
{{else}}
<div
class="dropzone {{if this.isDragging 'is-dragging'}}"
{{on "dragover" this.handleDragOver}}
{{on "dragleave" this.handleDragLeave}}
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop a photo here, or click to browse</p>
</label>
<input
id="photo-upload-input"
type="file"
accept="image/*"
class="file-input-hidden"
disabled={{this.isPublishing}}
{{on "change" this.handleFileSelect}}
/>
</div>
{{/if}}
</div>
</template>

View File

@@ -0,0 +1,189 @@
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
{{if photo.isLandscape 'landscape' 'portrait'}}"
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 router;
@service mapUi;
@service nostrData;
@action
createNewPlace() {
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
return !qp.q && !qp.category && qp.lat && qp.lon;
}
get hasHeaderPhoto() {
return (
this.args.selectedPlace &&
this.nostrData.placePhotos &&
this.nostrData.placePhotos.length > 0
);
}
<template>
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
{{#if @selectedPlace}}
<button
type="button"

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

View File

@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
async model(params) {
const id = params.place_id;
let type, osmId;
let isExplicitOsm = false;
if (
id.startsWith('osm:node:') ||
id.startsWith('osm:way:') ||
id.startsWith('osm:relation:')
) {
const [, type, osmId] = id.split(':');
isExplicitOsm = true;
[, type, osmId] = id.split(':');
console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type);
}
let backgroundFetchPromise = null;
if (isExplicitOsm) {
backgroundFetchPromise = this.loadOsmPlace(osmId, type);
}
await this.waitForSync();
let bookmark = this.storage.findPlaceById(id);
let lookupId = isExplicitOsm ? osmId : id;
let bookmark = this.storage.findPlaceById(lookupId);
// Ensure type matches if we are looking up by osmId
if (bookmark && isExplicitOsm && bookmark.osmType !== type) {
bookmark = null; // Type mismatch, not the same OSM object
}
if (bookmark) {
console.debug('Found in bookmarks:', bookmark.title);
return bookmark;
}
if (isExplicitOsm) {
console.debug(
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
osmId
);
return await backgroundFetchPromise;
}
console.warn('Not in bookmarks:', id);
return null;
}
@@ -119,14 +141,14 @@ export default class PlaceRoute extends Route {
}
serialize(model) {
// If the model is a saved bookmark, use its ID
if (model.id) {
return { place_id: model.id };
}
// If it's an OSM POI, use the explicit format
// If it's an OSM POI, use the explicit format first
if (model.osmId && model.osmType) {
return { place_id: `osm:${model.osmType}:${model.osmId}` };
}
// If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID
if (model.id) {
return { place_id: model.id };
}
// Fallback
return { place_id: model.osmId };
}

View File

@@ -21,10 +21,17 @@ function getBlossomUrl(serverUrl, path) {
export default class BlossomService extends Service {
@service nostrAuth;
@service nostrData;
@service settings;
get servers() {
const servers = this.nostrData.blossomServers;
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
if (!this.settings.nostrPhotoFallbackUploads) {
return [allServers[0]];
}
return allServers;
}
async _getAuthHeader(action, hash, serverUrl) {
@@ -53,10 +60,13 @@ export default class BlossomService extends Service {
return `Nostr ${base64url}`;
}
async _uploadToServer(file, hash, serverUrl) {
async _uploadToServer(file, hash, serverUrl, onProgress) {
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
if (onProgress) onProgress('signing');
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
if (onProgress) onProgress('uploading');
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, {
method: 'PUT',
@@ -102,14 +112,20 @@ export default class BlossomService extends Service {
if (options.sequential) {
// Sequential upload logic
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
mainResult = await this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
for (const serverUrl of fallbackServers) {
try {
const result = await this._uploadToServer(
file,
payloadHash,
serverUrl
serverUrl,
options.onProgress
);
fallbackUrls.push(result.url);
} catch (error) {
@@ -118,9 +134,14 @@ export default class BlossomService extends Service {
}
} else {
// Concurrent upload logic
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
const mainPromise = this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
const fallbackPromises = fallbackServers.map((serverUrl) =>
this._uploadToServer(file, payloadHash, serverUrl)
this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
);
// Main server MUST succeed

View File

@@ -1,7 +1,9 @@
import Service from '@ember/service';
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service {
@service nostrData;
@tracked selectedPlace = null;
@tracked isSearching = false;
@tracked isCreating = false;
@@ -19,12 +21,14 @@ export default class MapUiService extends Service {
selectPlace(place, options = {}) {
this.selectedPlace = place;
this.selectionOptions = options;
this.nostrData.loadPhotosForPlace(place);
}
clearSelection() {
this.selectedPlace = null;
this.selectionOptions = {};
this.preventNextZoom = false;
this.nostrData.loadPhotosForPlace(null);
}
setSearchResults(results) {

View File

@@ -4,32 +4,77 @@ import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
import { normalizeRelayUrl } from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const BOOTSTRAP_RELAYS = [
const DIRECTORY_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://nos.lol',
];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
const DEFAULT_WRITE_RELAYS = [];
export default class NostrDataService extends Service {
@service nostrRelay;
@service nostrAuth;
@service settings;
store = new EventStore();
@tracked profile = null;
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
_profileSub = null;
_mailboxesSub = null;
_blossomSub = null;
_photosSub = null;
_requestSub = null;
_cachePromise = null;
loadedGeohashPrefixes = new Set();
constructor() {
super(...arguments);
// Initialize the IndexedDB cache
this._cachePromise = openDB('applesauce-events').then(async (db) => {
this.cache = new NostrIDB(db, {
cacheIndexes: 1000,
maxEvents: 10000,
});
await this.cache.start();
// Automatically persist new events to the cache
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 10002 ||
e.kind === 10063 ||
e.kind === 360
);
if (toCache.length > 0) {
await Promise.all(toCache.map((event) => this.cache.add(event)));
}
},
{
batchTime: 1000, // Batch writes every 1 second
maxBatchSize: 100,
}
);
});
// Feed events from the relay pool into the event store
this.nostrRelay.pool.relays$.subscribe(() => {
// Setup relay subscription tracking if needed, or we just rely on request()
@@ -37,6 +82,177 @@ export default class NostrDataService extends Service {
});
}
get defaultReadRelays() {
const mailboxes = (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get defaultWriteRelays() {
const mailboxes = (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get activeReadRelays() {
if (this.settings.nostrReadRelays) {
return Array.from(
new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultReadRelays;
}
get activeWriteRelays() {
if (this.settings.nostrWriteRelays) {
return Array.from(
new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultWriteRelays;
}
async loadPlacesInBounds(bbox) {
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
const missingPrefixes = requiredPrefixes.filter(
(p) => !this.loadedGeohashPrefixes.has(p)
);
if (missingPrefixes.length === 0) {
return;
}
console.debug(
'[nostr-data] Loading place photos for prefixes:',
missingPrefixes
);
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
kinds: [360],
'#g': missingPrefixes,
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read photos from local Nostr IDB cache',
e
);
}
// Fire network request for new prefixes
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
'#g': missingPrefixes,
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error(
'[nostr-data] Error fetching place photos by geohash:',
err
);
},
});
for (const p of missingPrefixes) {
this.loadedGeohashPrefixes.add(p);
}
}
async loadPhotosForPlace(place) {
if (this._photosSub) {
this._photosSub.unsubscribe();
this._photosSub = null;
}
this.placePhotos = [];
if (!place || !place.osmId || !place.osmType) {
return;
}
const entityId = `osm:${place.osmType}:${place.osmId}`;
// Setup reactive store query
this._photosSub = this.store
.timeline([
{
kinds: [360],
'#i': [entityId],
},
])
.subscribe((events) => {
this.placePhotos = events;
});
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
kinds: [360],
'#i': [entityId],
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read photos for place from local Nostr IDB cache',
e
);
}
// Fire network request specifically for this place
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
'#i': [entityId],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error(
'[nostr-data] Error fetching place photos for place:',
err
);
},
});
}
async loadProfile(pubkey) {
if (!pubkey) return;
@@ -47,40 +263,8 @@ export default class NostrDataService extends Service {
this._cleanupSubscriptions();
const relays = new Set(BOOTSTRAP_RELAYS);
// Try to get extension relays
if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
try {
const extRelays = await window.nostr.getRelays();
for (const url of Object.keys(extRelays)) {
relays.add(url);
}
} catch {
console.warn('Failed to get NIP-07 relays');
}
}
const relayList = Array.from(relays);
// Request events and dump them into the store
this._requestSub = this.nostrRelay.pool
.request(relayList, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('Error fetching profile events:', err);
},
});
// Setup models to track state reactively
// Setup models to track state reactively FIRST
// This way, if cached events populate the store, the UI updates instantly.
this._profileSub = this.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
@@ -104,6 +288,46 @@ export default class NostrDataService extends Service {
this.blossomServers = [];
}
});
// 1. Await cache initialization and populate the EventStore with local data
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn('Failed to read from local Nostr IDB cache', e);
}
// 2. Request new events from the network in the background and dump them into the store
const profileRelays = Array.from(
new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
);
this._requestSub = this.nostrRelay.pool
.request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('Error fetching profile events:', err);
},
});
}
get userDisplayName() {
@@ -132,6 +356,13 @@ export default class NostrDataService extends Service {
return 'Not connected';
}
async clearCache() {
await this._cachePromise;
if (this.cache) {
await this.cache.deleteAllEvents();
}
}
_cleanupSubscriptions() {
if (this._requestSub) {
this._requestSub.unsubscribe();
@@ -149,10 +380,22 @@ export default class NostrDataService extends Service {
this._blossomSub.unsubscribe();
this._blossomSub = null;
}
if (this._photosSub) {
this._photosSub.unsubscribe();
this._photosSub = null;
}
}
willDestroy() {
super.willDestroy(...arguments);
this._cleanupSubscriptions();
if (this._stopPersisting) {
this._stopPersisting();
}
if (this.cache) {
this.cache.stop();
}
}
}

View File

@@ -4,13 +4,13 @@ import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service {
pool = new RelayPool();
// For Phase 1, we hardcode the local relay
relays = ['ws://127.0.0.1:7777'];
async publish(event) {
async publish(relays, event) {
if (!relays || relays.length === 0) {
throw new Error('No relays provided to publish the event.');
}
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
// and automatically handles reconnecting and retrying.
const responses = await this.pool.publish(this.relays, event);
const responses = await this.pool.publish(relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);

View File

@@ -8,6 +8,7 @@ export default class OsmService extends Service {
controller = null;
cachedResults = null;
lastQueryKey = null;
cachedPlaces = new Map();
cancelAll() {
if (this.controller) {
@@ -232,6 +233,13 @@ out center;
async fetchOsmObject(osmId, osmType) {
if (!osmId || !osmType) return null;
const cacheKey = `${osmType}:${osmId}`;
const cached = this.cachedPlaces.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 10000) {
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
return cached.data;
}
let url;
if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
@@ -253,8 +261,25 @@ out center;
}
throw new Error(`OSM API request failed: ${res.status}`);
}
const data = await res.json();
return this.normalizeOsmApiData(data.elements, osmId, osmType);
const normalizedData = this.normalizeOsmApiData(
data.elements,
osmId,
osmType
);
this.cachedPlaces.set(cacheKey, {
data: normalizedData,
timestamp: Date.now(),
});
// Cleanup cache entry automatically after 10 seconds
setTimeout(() => {
this.cachedPlaces.delete(cacheKey);
}, 10000);
return normalizedData;
} catch (e) {
console.error('Failed to fetch OSM object:', e);
return null;

View File

@@ -1,11 +1,25 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
const DEFAULT_SETTINGS = {
overpassApi: 'https://overpass-api.de/api/interpreter',
mapKinetic: true,
photonApi: 'https://photon.komoot.io/api/',
showQuickSearchButtons: true,
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
};
export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
@tracked mapKinetic = true;
@tracked photonApi = 'https://photon.komoot.io/api/';
@tracked showQuickSearchButtons = true;
@tracked overpassApi = DEFAULT_SETTINGS.overpassApi;
@tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic;
@tracked photonApi = DEFAULT_SETTINGS.photonApi;
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
@tracked nostrPhotoFallbackUploads =
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
overpassApis = [
{
@@ -39,49 +53,83 @@ export default class SettingsService extends Service {
}
loadSettings() {
const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) {
// Check if saved API is still in the allowed list
const isValid = this.overpassApis.some((api) => api.url === savedApi);
if (isValid) {
this.overpassApi = savedApi;
} else {
// If not valid, revert to default
this.overpassApi = 'https://overpass-api.de/api/interpreter';
localStorage.setItem('marco:overpass-api', this.overpassApi);
let settings = {};
const savedSettings = localStorage.getItem('marco:settings');
if (savedSettings) {
try {
settings = JSON.parse(savedSettings);
} catch (e) {
console.error('Failed to parse settings from localStorage', e);
}
} else {
// Migration from old individual keys
const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) settings.overpassApi = savedApi;
const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true';
const savedShowQuickSearch = localStorage.getItem(
'marco:show-quick-search'
);
if (savedShowQuickSearch !== null) {
settings.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
const savedNostrPhotoFallbackUploads = localStorage.getItem(
'marco:nostr-photo-fallback-uploads'
);
if (savedNostrPhotoFallbackUploads !== null) {
settings.nostrPhotoFallbackUploads =
savedNostrPhotoFallbackUploads === 'true';
}
const savedPhotonApi = localStorage.getItem('marco:photon-api');
if (savedPhotonApi) settings.photonApi = savedPhotonApi;
}
const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) {
this.mapKinetic = savedKinetic === 'true';
}
// Default is true (initialized in class field)
// Merge with defaults
const finalSettings = { ...DEFAULT_SETTINGS, ...settings };
const savedShowQuickSearch = localStorage.getItem(
'marco:show-quick-search'
// Validate overpass API
const isValid = this.overpassApis.some(
(api) => api.url === finalSettings.overpassApi
);
if (savedShowQuickSearch !== null) {
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
if (!isValid) {
finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi;
}
// Apply to tracked properties
this.overpassApi = finalSettings.overpassApi;
this.mapKinetic = finalSettings.mapKinetic;
this.photonApi = finalSettings.photonApi;
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
// Save to ensure migrated settings are stored in the new format
this.saveSettings();
}
updateOverpassApi(url) {
this.overpassApi = url;
localStorage.setItem('marco:overpass-api', url);
saveSettings() {
const settings = {
overpassApi: this.overpassApi,
mapKinetic: this.mapKinetic,
photonApi: this.photonApi,
showQuickSearchButtons: this.showQuickSearchButtons,
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
}
updateMapKinetic(enabled) {
this.mapKinetic = enabled;
localStorage.setItem('marco:map-kinetic', String(enabled));
}
updateShowQuickSearchButtons(enabled) {
this.showQuickSearchButtons = enabled;
localStorage.setItem('marco:show-quick-search', String(enabled));
}
updatePhotonApi(url) {
this.photonApi = url;
update(key, value) {
if (key in DEFAULT_SETTINGS) {
this[key] = value;
this.saveSettings();
}
}
}

View File

@@ -8,6 +8,8 @@
--link-color-visited: #6a4fbf;
--marker-color-primary: #ea4335;
--marker-color-dark: #b31412;
--danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark);
}
html,
@@ -213,12 +215,12 @@ body {
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 2rem 1.5rem;
text-align: center;
transition: all 0.2s ease;
margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
aspect-ratio: 4 / 3;
}
.dropzone.is-dragging {
@@ -230,9 +232,12 @@ body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
cursor: pointer;
color: #898989;
width: 100%;
height: 100%;
}
.dropzone-label p {
@@ -244,25 +249,35 @@ body {
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item {
position: relative;
aspect-ratio: 1 / 1;
aspect-ratio: 4 / 3;
border-radius: 6px;
overflow: hidden;
background: #1e262e;
width: 100%;
}
.photo-upload-item img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
display: block;
z-index: 1;
}
.photo-upload-item .overlay,
.photo-upload-item .btn-remove-photo {
z-index: 2;
}
.photo-upload-item .overlay {
@@ -270,10 +285,20 @@ body {
inset: 0;
background: rgb(0 0 0 / 60%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-status-text {
color: white;
margin-top: 1rem;
font-size: 0.9rem;
text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
text-align: center;
padding: 0 1rem;
}
.photo-upload-item .error-overlay {
background: rgb(224 108 117 / 80%);
cursor: pointer;
@@ -301,7 +326,7 @@ body {
}
.photo-upload-item .btn-remove-photo:hover {
background: var(--marker-color-primary);
background: var(--danger-color);
}
.spin-animation {
@@ -460,6 +485,10 @@ body {
align-items: center;
}
.sidebar-header.no-border {
border-bottom-color: transparent;
}
.sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
@@ -565,6 +594,64 @@ body {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem;
display: flex;
flex-direction: column;
gap: 16px;
}
.relay-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.relay-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
border-radius: 4px;
font-size: 0.9rem;
word-break: break-all;
}
.btn-remove-relay {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--danger-color);
color: var(--danger-color);
cursor: pointer;
padding: 0;
transition: all 0.1s ease;
flex-shrink: 0;
}
.btn-remove-relay svg {
stroke: currentcolor;
}
.btn-remove-relay:hover,
.btn-remove-relay:active {
background-color: var(--danger-color);
color: #fff;
}
.add-relay-input {
display: flex;
gap: 0.5rem;
}
.btn-link.reset-relays {
margin-top: 0.75rem;
font-size: 0.85rem;
}
@keyframes details-slide-down {
@@ -595,7 +682,7 @@ body {
display: block;
font-size: 0.85rem;
color: #666;
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
.form-control {
@@ -639,6 +726,11 @@ select.form-control {
}
.settings-section .form-group {
margin-top: 0.5rem;
margin-bottom: 0;
}
.settings-section .form-group:first-of-type {
margin-top: 1rem;
}
@@ -692,12 +784,19 @@ select.form-control {
border-top: 1px solid #eee;
}
.meta-info a {
.meta-info a,
.meta-info .btn-link {
color: var(--link-color);
text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
}
.meta-info a:hover {
.meta-info a:hover,
.meta-info .btn-link:hover {
text-decoration: underline;
}
@@ -791,6 +890,160 @@ abbr[title] {
padding-bottom: 2rem;
}
.place-photos-carousel-wrapper {
position: relative;
margin: -1rem -1rem 1rem;
}
.place-photos-carousel-track {
display: flex;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none; /* Firefox */
background-color: var(--hover-bg);
}
.place-photos-carousel-track::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.carousel-slide {
position: relative;
flex: 0 0 100%;
scroll-snap-align: start;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.carousel-placeholder {
display: none;
}
.place-header-photo-blur {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.place-header-photo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
opacity: 0;
transition: opacity 0.3s ease-in-out;
z-index: 1; /* Stay above blurhash */
}
.place-header-photo.landscape {
object-fit: cover;
}
.place-header-photo.portrait {
object-fit: contain;
}
.place-header-photo.loaded {
opacity: 1;
}
.place-header-photo.loaded-instant {
opacity: 1;
transition: none;
}
.carousel-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgb(0 0 0 / 50%);
color: white;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
opacity: 0;
transition:
opacity 0.2s,
background-color 0.2s;
padding: 0;
}
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
opacity: 1;
}
.carousel-nav-btn:not(.disabled):hover {
background: rgb(0 0 0 / 80%);
}
.carousel-nav-btn.disabled {
opacity: 0;
pointer-events: none;
}
.carousel-nav-btn.prev {
left: 0.5rem;
}
.carousel-nav-btn.next {
right: 0.5rem;
}
@media (width <= 768px) {
.place-photos-carousel-track {
scroll-snap-type: none;
gap: 2px;
background-color: #fff;
}
.carousel-slide {
flex: 0 0 auto;
height: 100px;
width: auto;
scroll-snap-align: none;
}
.carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9);
}
.carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.carousel-placeholder {
display: block;
background-color: var(--hover-bg);
flex: 1 1 0%;
min-width: 0;
}
.place-header-photo.landscape,
.place-header-photo.portrait {
object-fit: cover;
}
.carousel-nav-btn {
display: none;
}
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
@@ -967,6 +1220,7 @@ abbr[title] {
display: inline-flex;
width: 32px;
height: 32px;
margin: -6px 0;
}
.app-logo-icon svg {
@@ -1561,6 +1815,12 @@ button.create-place {
top: 1rem;
right: 1rem;
cursor: pointer;
color: #898989;
}
.close-modal-btn.disabled {
color: #ccc;
cursor: not-allowed;
}
.place-photo-upload h2 {
@@ -1579,11 +1839,6 @@ button.create-place {
color: #c00;
}
.alert-info {
background: #eef;
color: #00c;
}
.preview-group {
margin-bottom: 1rem;
}
@@ -1597,3 +1852,17 @@ button.create-place {
max-width: 100%;
border-radius: 0.25rem;
}
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--link-color);
text-decoration: none;
cursor: pointer;
font: inherit;
}
.btn-link:hover {
text-decoration: underline;
}

View File

@@ -2,9 +2,12 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import camera from 'feather-icons/dist/icons/camera.svg?raw';
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw';
@@ -42,7 +45,9 @@ import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bridge from '@waysidemapping/pinhead/dist/icons/bridge.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
@@ -73,6 +78,7 @@ import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
@@ -100,6 +106,7 @@ import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_str
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
@@ -129,9 +136,13 @@ const ICONS = {
bookmark,
'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bridge,
bus,
camera,
'feather-camera': featherCamera,
'check-square': checkSquare,
'chevron-left': chevronLeft,
'chevron-right': chevronRight,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
climbing_wall: climbingWall,
check,
@@ -149,6 +160,7 @@ const ICONS = {
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
croissant,
'cup-and-saucer': cupAndSaucer,
database,
donut,
edit,
eyeglasses,
@@ -170,6 +182,7 @@ const ICONS = {
heart,
home,
'ice-cream-on-cone': iceCreamOnCone,
'industrial-building': industrialBuilding,
info,
instagram,
jewel,
@@ -210,6 +223,7 @@ const ICONS = {
'sailing-ship-in-water': sailingShipInWater,
'scissors-open': scissorsOpen,
'shipwreck-in-water': shipwreckInWater,
'steam-train-on-railway-track': steamTrainOnRailwayTrack,
'shopping-bag': shoppingBag,
search,
server,

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

@@ -109,6 +109,7 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
// Historic
{ tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
@@ -119,6 +120,12 @@ export const POI_ICON_RULES = [
tags: { historic: 'monument' },
icon: 'classical-building-with-dome-and-flag',
},
{ tags: { historic: 'folly' }, icon: 'classical-building' },
{ tags: { historic: 'industrial' }, icon: 'industrial-building' },
{
tags: { historic: 'railway_station' },
icon: 'steam-train-on-railway-track',
},
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
{ tags: { historic: 'ruins' }, icon: 'camera' },

View File

@@ -41,7 +41,7 @@ export const POI_CATEGORIES = [
{
id: 'things-to-do',
label: 'Things to do',
icon: 'camera',
icon: 'feather-camera',
filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
types: ['node', 'way', 'relation'],
},
];

View File

@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
## Content
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
## Tags
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
#### 3. `imeta` — Inline Media Metadata
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` MAY also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
```json
[
"imeta",
"url https://example.com/photo.jpg",
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"m image/jpeg",
"dim 3024x4032",
"dim 1440x1920",
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
]
```
@@ -83,10 +85,12 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
[
"imeta",
"url https://example.com/ramen.jpg",
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"m image/jpeg",
"dim 1080x1080",
"dim 1920x1920",
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
],
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
## Rationale
### Kind 360
Easy to remember as a 360-degree view of places.
### Why not use NIP-68 (Picture-first feeds)?
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
@@ -105,3 +113,7 @@ NIP-68 is designed for general-purpose social feeds (like Instagram). Place phot
### Separation from Place Reviews
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
### Single Photo per Event
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.

View File

@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
## Rationale
### Kind 30360
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
### No Place Field in Content
Avoids duplication and inconsistency with tags.

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.19.1",
"version": "1.20.4",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -111,6 +111,7 @@
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"nostr-idb": "^5.0.0",
"oauth2-pkce": "^2.1.3",
"qrcode": "^1.5.4",
"rxjs": "^7.8.2"

18
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
ember-lifeline:
specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
nostr-idb:
specifier: ^5.0.0
version: 5.0.0
oauth2-pkce:
specifier: ^2.1.3
version: 2.1.3
@@ -3709,6 +3712,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -4405,6 +4411,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
nostr-idb@5.0.0:
resolution: {integrity: sha512-w5y4AnHefZIwCCL11NryfM2xp3U0Ka4qVNQEYAjnQbPwyoV+bZTdwuPXHCdRDWvhOFP2bZr1WBegcsAmkBjrxQ==}
nostr-signer-capacitor-plugin@0.0.5:
resolution: {integrity: sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==}
peerDependencies:
@@ -10286,6 +10295,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
idb@8.0.3: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -11021,6 +11032,13 @@ snapshots:
normalize-path@3.0.0: {}
nostr-idb@5.0.0:
dependencies:
debug: 4.4.3
idb: 8.0.3
transitivePeerDependencies:
- supports-color
nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.2):
dependencies:
'@capacitor/core': 7.6.2

View File

@@ -0,0 +1 @@
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-BVEi_-zb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BF2Ls-fG.css">
<script type="module" crossorigin src="/assets/main-CIpd5fcK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css">
</head>
<body>
</body>

View File

@@ -3,6 +3,7 @@ import {
setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest,
} from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr';
// This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is
@@ -10,6 +11,7 @@ import {
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for application tests can be done here.
//
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for rendering tests can be done here.
}
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
setupNostrMocks(hooks);
// Additional setup for unit tests can be done here.
}

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