Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdeb4f5d8c
|
||
|
|
8494f5a084
|
||
|
|
bc433c8b1e
|
||
|
|
c3af496dfd
|
||
|
|
67e4eaddbb
|
||
|
|
beda860093
|
||
|
|
82bc6a424e
|
||
|
|
74e9860e08
|
||
|
|
135a30f1cd
|
||
|
|
99ebdf3378
|
||
|
|
20bac8ae31
|
||
|
|
2869ed6677
|
||
|
|
644d6a2856
|
||
|
|
09aefa6e19
|
||
|
|
c5198b39b8
|
||
|
|
1387f955d0
|
||
|
|
2d547dde87
|
||
|
|
85334ec15d
|
||
|
|
bfcf855020
|
||
|
|
0f0874a07a
|
||
|
|
c9a6304926
|
||
|
|
6fb4ecede3
|
||
|
|
77db625b11
|
||
|
|
6351cfbd07
|
||
|
|
b7e46da3e9
|
||
|
|
3233150cc4
|
||
|
|
822472a0e6
|
||
|
|
7ede81fe5f
|
||
|
|
3d2b723e05
|
||
|
|
10ecb745ee
|
||
|
|
59243d7703
|
||
|
|
5bdb90c6f0
|
||
|
|
55a4a7a2da
|
||
|
|
1de276fcc8
|
||
|
|
61242420d1
|
||
|
|
45f6f898fa
|
||
|
|
21d261ef17
|
||
|
|
3d578177ab
|
||
|
|
df9831b7c3
|
||
|
|
78687b63ff
|
||
|
|
d6e47a1b27
|
||
|
|
42cd620c48
|
||
|
|
b9e5213c27
|
||
|
|
d6fddddd4e
|
||
|
|
335ff1a9d8
|
||
|
|
9dc080b86e
|
||
|
|
a457e1521b
|
||
|
|
39ab5e2cd1
|
||
|
|
ce53d42581
|
||
|
|
387fd6efed
|
||
|
|
aaf0d8c3e9
|
||
|
|
9807a2b822
|
||
|
|
ac9870240f
|
||
|
|
1b185d1033
|
||
|
|
4d4ed8c1ea
|
||
|
|
1613ebf0bf
|
||
|
|
4cb8c94c9c
|
||
|
|
1f87c802e1
|
||
|
|
a6576fcda8
|
||
|
|
1822c4893f
|
||
|
|
39dca446ac
|
||
|
|
2e6827f0d0
|
||
|
|
8c3a805684
|
||
|
|
cf80c1eea7
|
||
|
|
126806fcd4
|
||
|
|
f48045c35d
|
||
|
|
989173a2e6
|
||
|
|
62407f5fa4
|
||
|
|
7f1c4b5f61
|
||
|
|
59e334c499
|
||
|
|
981502e293
|
||
|
|
9c5fbca72a
|
||
|
|
fd8f04f902
|
||
|
|
3e23ec161c
|
||
|
|
f2e531c0f6
|
||
|
|
96a5a6ac34
|
||
|
|
78996b6c61
|
||
|
|
bb5b69711c
|
||
|
|
ad9c489102
|
||
|
|
c11882adfb
|
||
|
|
707f4ac11c
|
||
|
|
3bada05b63
|
||
|
|
f01730fef5
|
||
|
|
448c51bab6
|
||
|
|
0bcbae374b
|
||
|
|
c33fe3b268
|
||
|
|
18bda60310
|
||
|
|
86d25dc6ba
|
||
|
|
5b8bec6a00
|
||
|
|
f2c2eb1fdc
|
||
|
|
b42c4881f6
|
||
|
|
b18e299eca
|
||
|
|
401ed41fcd
|
||
|
|
504e8fab94
|
||
|
|
76897c9e69
|
||
|
|
59bc5ca046
|
||
|
|
ef4bb8f51a
|
||
|
|
f82a797720
|
||
|
|
f9cb22ee0e
|
||
|
|
a77ea0c97d
|
||
|
|
208b77a294
|
||
|
|
ea3e4dd0dc
|
||
|
|
2c2a3e2a4c
|
||
|
|
d266bb92bd
|
||
|
|
200100686d
|
||
|
|
70d2fe1c6c
|
||
|
|
6329ad986d
|
||
|
|
bcfa81494e
|
||
|
|
bc42694707
|
||
|
|
4390b7d699
|
||
|
|
7bab8dfa09
|
||
|
|
51c9555273
|
||
|
|
632efeeab5
|
||
|
|
14827fce3e
|
||
|
|
deeea9961f
|
||
|
|
7a109c9ba5
|
||
|
|
10aae3c9b3
|
||
|
|
b492e2aa89
|
||
|
|
4c4a53ae42
|
||
|
|
a240a5d199
|
||
|
|
0332cf4c3c
|
||
|
|
59c447fe1f
|
||
|
|
1140ecfe41
|
||
|
|
60936ed2f5
|
||
|
|
ca82a029bc
|
||
|
|
0630aed73d
|
||
|
|
f27a636529
|
||
|
|
995ae95b09
|
||
|
|
0fb320d996
|
||
|
|
4f4ca827b1
|
||
|
|
c1d3f25d50
|
||
|
|
2087cfc4f7
|
||
|
|
8572032481
|
||
|
|
b4c3f5c88d
|
||
|
|
cff19980d5
|
||
|
|
cf251f702b
|
||
|
|
d2eb888dcf
|
||
|
|
a0b4a4b3f3
|
||
|
|
cb3ee48909
|
||
|
|
1d022b21bd
|
||
|
|
3e831a7686
|
||
|
|
2943125dbd
|
||
|
|
a32ad7572b
|
||
|
|
a1b3957c83
|
||
|
|
9f2f233c22
|
||
|
|
1ba4afdf08
|
||
|
|
d764134513
|
||
|
|
e38f540c79
|
||
|
|
73ad5b4eb1
|
||
|
|
b4a70233cf
|
||
|
|
cb4b9c6b40
|
||
|
|
98dcb4f25b
|
||
|
|
7709634a9a
|
||
|
|
3ddc85669f
|
||
|
|
95961e680f
|
||
|
|
9468a6a0cc
|
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,7 @@ Each rule file contains:
|
|||||||
Ember has excellent accessibility support through community addons:
|
Ember has excellent accessibility support through community addons:
|
||||||
|
|
||||||
- **ember-a11y-testing** - Automated accessibility testing in your test suite
|
- **ember-a11y-testing** - Automated accessibility testing in your test suite
|
||||||
- **ember-a11y** - Route announcements and focus management
|
- **ember-a11y-refocus** - Route announcements and focus management
|
||||||
- **ember-focus-trap** - Focus trapping for modals and dialogs
|
- **ember-focus-trap** - Focus trapping for modals and dialogs
|
||||||
- **ember-page-title** - Accessible page title management
|
- **ember-page-title** - Accessible page title management
|
||||||
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
|
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
|
|||||||
setupGlobalA11yHooks(); // Runs on every test automatically
|
setupGlobalA11yHooks(); // Runs on every test automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Leave All Rules Enabled
|
||||||
|
|
||||||
|
Keep the default ember-a11y-testing and axe-core rules turned on. Avoid disabling rules globally or excluding specific rules to bypass failures without a documented remediation plan.
|
||||||
|
|
||||||
|
When teams suppress accessibility rules without a time-boxed remediation plan, they hide real defects, accumulate technical debt, and make regressions harder to detect. A skipped rule can allow a serious accessibility defect to ship to production, especially for problems involving forms, keyboard access, focus management, semantics, or ARIA usage.
|
||||||
|
|
||||||
|
If you must suppress a rule temporarily, treat it as an exception: document why it is needed, scope it as narrowly as possible, and create follow-up work to restore the rule quickly.
|
||||||
|
|
||||||
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
|
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
|
||||||
|
|
||||||
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
|
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ All form inputs must have associated labels, and validation errors should be ann
|
|||||||
<div>
|
<div>
|
||||||
<label for="email-input">
|
<label for="email-input">
|
||||||
Email Address
|
Email Address
|
||||||
{{#if this.isEmailRequired}}
|
<span aria-hidden="true">*</span>
|
||||||
<span aria-label="required">*</span>
|
|
||||||
{{/if}}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -19,49 +19,86 @@ export default class Router extends EmberRouter {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (using a11y-announcer library - recommended):**
|
**Correct (using ember-a11y-refocus library - recommended):**
|
||||||
|
|
||||||
Use the [a11y-announcer](https://github.com/ember-a11y/a11y-announcer) library for robust route announcements:
|
Use the [ember-a11y-refocus](https://github.com/ember-a11y/ember-a11y-refocus) library for robust route announcements, route transition focus management, and a bypass block (aka skip link).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ember install @ember-a11y/a11y-announcer
|
pnpm add ember-a11y-refocus
|
||||||
```
|
```
|
||||||
|
|
||||||
```javascript
|
Or with npm:
|
||||||
// app/router.js
|
|
||||||
import EmberRouter from '@ember/routing/router';
|
|
||||||
import config from './config/environment';
|
|
||||||
|
|
||||||
export default class Router extends EmberRouter {
|
```bash
|
||||||
location = config.locationType;
|
npm install ember-a11y-refocus
|
||||||
rootURL = config.rootURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
Router.map(function () {
|
|
||||||
this.route('about');
|
|
||||||
this.route('dashboard');
|
|
||||||
this.route('posts', function () {
|
|
||||||
this.route('post', { path: '/:post_id' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:
|
Use the addon by rendering `NavigationNarrator` in your application layout and ensuring your primary content has `id="main"`.
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{! app/templates/application.hbs }}
|
||||||
|
<header>
|
||||||
|
<NavigationNarrator />
|
||||||
|
{{! other header content }}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id='main'>
|
||||||
|
{{outlet}}
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using GJS or GTS, import the component directly:
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
import { NavigationNarrator } from 'ember-a11y-refocus';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<NavigationNarrator />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
{{outlet}}
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
The addon ships minimal styles for the skip link and navigation message:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// app/routes/dashboard.js
|
// app/app.js or app/app.ts
|
||||||
import Route from '@ember/routing/route';
|
import 'ember-a11y-refocus/styles/navigation-narrator.css';
|
||||||
import { service } from '@ember/service';
|
```
|
||||||
|
|
||||||
export default class DashboardRoute extends Route {
|
If you need to customize which transitions count as a route change, pass a validator function to `NavigationNarrator`:
|
||||||
@service announcer;
|
|
||||||
|
|
||||||
afterModel() {
|
```javascript
|
||||||
this.announcer.announce('Loaded dashboard with latest data');
|
// app/controllers/application.js
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
import { defaultValidator } from 'ember-a11y-refocus';
|
||||||
|
|
||||||
|
export default class ApplicationController extends Controller {
|
||||||
|
myCustomValidator(transition) {
|
||||||
|
if (transition.from?.name === 'special') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValidator(transition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```handlebars
|
||||||
|
{{! app/templates/application.hbs }}
|
||||||
|
<header>
|
||||||
|
<NavigationNarrator @routeChangeValidator={{this.myCustomValidator}} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id='main'>
|
||||||
|
{{outlet}}
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
**Alternative: DIY approach with ARIA live regions:**
|
**Alternative: DIY approach with ARIA live regions:**
|
||||||
|
|
||||||
If you prefer not to use a library, you can implement route announcements yourself:
|
If you prefer not to use a library, you can implement route announcements yourself:
|
||||||
@@ -150,25 +187,6 @@ export default class ApplicationRoute extends Route {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Alternative: Use ember-page-title with announcements:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ember install ember-page-title
|
|
||||||
```
|
|
||||||
|
|
||||||
```glimmer-js
|
|
||||||
// app/routes/dashboard.gjs
|
|
||||||
import { pageTitle } from 'ember-page-title';
|
|
||||||
|
|
||||||
<template>
|
|
||||||
{{pageTitle "Dashboard"}}
|
|
||||||
|
|
||||||
<div class="dashboard">
|
|
||||||
{{outlet}}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
|
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
|
||||||
|
|
||||||
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
|
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ Implement intelligent model caching strategies to reduce redundant API calls and
|
|||||||
|
|
||||||
**Incorrect (always fetches fresh data):**
|
**Incorrect (always fetches fresh data):**
|
||||||
|
|
||||||
```glimmer-js
|
```javascript
|
||||||
// app/routes/post.gjs
|
// app/routes/post.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -23,21 +23,24 @@ export default class PostRoute extends Route {
|
|||||||
// Always makes API call, even if we just loaded this post
|
// Always makes API call, even if we just loaded this post
|
||||||
return this.store.request({ url: `/posts/${params.post_id}` });
|
return this.store.request({ url: `/posts/${params.post_id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<h1>{{@model.title}}</h1>
|
|
||||||
<div>{{@model.content}}</div>
|
|
||||||
</article>
|
|
||||||
{{outlet}}
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/post.gjs
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h1>{{@model.title}}</h1>
|
||||||
|
<div>{{@model.content}}</div>
|
||||||
|
</article>
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
**Correct (with smart caching):**
|
**Correct (with smart caching):**
|
||||||
|
|
||||||
```glimmer-js
|
```javascript
|
||||||
// app/routes/post.gjs
|
// app/routes/post.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -68,17 +71,20 @@ export default class PostRoute extends Route {
|
|||||||
const fiveMinutes = 5 * 60 * 1000;
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
return Date.now() - cacheTime < fiveMinutes;
|
return Date.now() - cacheTime < fiveMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<h1>{{@model.title}}</h1>
|
|
||||||
<div>{{@model.content}}</div>
|
|
||||||
</article>
|
|
||||||
{{outlet}}
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/post.gjs
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h1>{{@model.title}}</h1>
|
||||||
|
<div>{{@model.content}}</div>
|
||||||
|
</article>
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
**Service-based caching layer:**
|
**Service-based caching layer:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@@ -123,8 +129,8 @@ export default class PostCacheService extends Service {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```glimmer-js
|
```javascript
|
||||||
// app/routes/post.gjs
|
// app/routes/post.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -141,21 +147,24 @@ export default class PostRoute extends Route {
|
|||||||
const params = this.paramsFor('post');
|
const params = this.paramsFor('post');
|
||||||
await this.postCache.getPost(params.post_id, { forceRefresh: true });
|
await this.postCache.getPost(params.post_id, { forceRefresh: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<h1>{{@model.title}}</h1>
|
|
||||||
<div>{{@model.content}}</div>
|
|
||||||
</article>
|
|
||||||
{{outlet}}
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/post.gjs
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<h1>{{@model.title}}</h1>
|
||||||
|
<div>{{@model.content}}</div>
|
||||||
|
</article>
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
**Using query params for cache control:**
|
**Using query params for cache control:**
|
||||||
|
|
||||||
```glimmer-js
|
```javascript
|
||||||
// app/routes/posts.gjs
|
// app/routes/posts.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -174,28 +183,31 @@ export default class PostsRoute extends Route {
|
|||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="posts">
|
|
||||||
<button {{on "click" (fn this.refresh)}}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
{{#each @model as |post|}}
|
|
||||||
<li>{{post.title}}</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{outlet}}
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/posts.gjs
|
||||||
|
<template>
|
||||||
|
<div class="posts">
|
||||||
|
<button {{on "click" (fn this.refresh)}}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{{#each @model as |post|}}
|
||||||
|
<li>{{post.title}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
**Background refresh pattern:**
|
**Background refresh pattern:**
|
||||||
|
|
||||||
```glimmer-js
|
```javascript
|
||||||
// app/routes/dashboard.gjs
|
// app/routes/dashboard.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -214,17 +226,20 @@ export default class DashboardRoute extends Route {
|
|||||||
|
|
||||||
return cached || this.store.request({ url: '/dashboard' });
|
return cached || this.store.request({ url: '/dashboard' });
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="dashboard">
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<div>Stats: {{@model.stats}}</div>
|
|
||||||
</div>
|
|
||||||
{{outlet}}
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/dashboard.gjs
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div>Stats: {{@model.stats}}</div>
|
||||||
|
</div>
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
|
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
|
||||||
|
|
||||||
Reference: [WarpDrive Caching](https://warp-drive.io/)
|
Reference: [WarpDrive Caching](https://warp-drive.io/)
|
||||||
|
|||||||
@@ -1,38 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Use Route Templates with Co-located Syntax
|
title: Use Separate Route and Template Files
|
||||||
impact: MEDIUM-HIGH
|
impact: MEDIUM-HIGH
|
||||||
impactDescription: Better code organization and maintainability
|
impactDescription: Better code organization and maintainability
|
||||||
tags: routes, templates, gjs, co-location
|
tags: routes, templates, gjs, file-conventions
|
||||||
---
|
---
|
||||||
|
|
||||||
## Use Route Templates with Co-located Syntax
|
## Use Separate Route and Template Files
|
||||||
|
|
||||||
Use co-located route templates with modern gjs syntax for better organization and maintainability.
|
Keep route logic in `app/routes/*.js` and route templates in `app/templates/*.gjs`. Route classes imported from `@ember/routing/route` do not support inline `<template>` blocks.
|
||||||
|
|
||||||
**Incorrect (separate template file - old pattern):**
|
**Incorrect (inline template inside a route class):**
|
||||||
|
|
||||||
```glimmer-js
|
|
||||||
// app/routes/posts.js (separate file)
|
|
||||||
import Route from '@ember/routing/route';
|
|
||||||
|
|
||||||
export default class PostsRoute extends Route {
|
|
||||||
model() {
|
|
||||||
return this.store.request({ url: '/posts' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// app/templates/posts.gjs (separate template file)
|
|
||||||
<template>
|
|
||||||
<h1>Posts</h1>
|
|
||||||
<ul>
|
|
||||||
{{#each @model as |post|}}
|
|
||||||
<li>{{post.title}}</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Correct (co-located route template):**
|
|
||||||
|
|
||||||
```glimmer-js
|
```glimmer-js
|
||||||
// app/routes/posts.gjs
|
// app/routes/posts.gjs
|
||||||
@@ -56,10 +33,37 @@ export default class PostsRoute extends Route {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**With loading and error states:**
|
**Correct (separate route module and template file):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// app/routes/posts.js
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
|
||||||
|
export default class PostsRoute extends Route {
|
||||||
|
model() {
|
||||||
|
return this.store.request({ url: '/posts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```glimmer-js
|
```glimmer-js
|
||||||
// app/routes/posts.gjs
|
// app/templates/posts.gjs
|
||||||
|
<template>
|
||||||
|
<h1>Posts</h1>
|
||||||
|
<ul>
|
||||||
|
{{#each @model as |post|}}
|
||||||
|
<li>{{post.title}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{outlet}}
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**With a separate template file for route UI:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// app/routes/posts.js
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
@@ -69,29 +73,32 @@ export default class PostsRoute extends Route {
|
|||||||
model() {
|
model() {
|
||||||
return this.store.request({ url: '/posts' });
|
return this.store.request({ url: '/posts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="posts-page">
|
|
||||||
<h1>Posts</h1>
|
|
||||||
|
|
||||||
{{#if @model}}
|
|
||||||
<ul>
|
|
||||||
{{#each @model as |post|}}
|
|
||||||
<li>{{post.title}}</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{outlet}}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```glimmer-js
|
||||||
|
// app/templates/posts.gjs
|
||||||
|
<template>
|
||||||
|
<div class="posts-page">
|
||||||
|
<h1>Posts</h1>
|
||||||
|
|
||||||
|
{{#if @model}}
|
||||||
|
<ul>
|
||||||
|
{{#each @model as |post|}}
|
||||||
|
<li>{{post.title}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{outlet}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
**Template-only routes:**
|
**Template-only routes:**
|
||||||
|
|
||||||
```glimmer-js
|
```glimmer-js
|
||||||
// app/routes/about.gjs
|
// app/templates/about.gjs
|
||||||
<template>
|
<template>
|
||||||
<div class="about-page">
|
<div class="about-page">
|
||||||
<h1>About Us</h1>
|
<h1>About Us</h1>
|
||||||
@@ -100,6 +107,6 @@ export default class PostsRoute extends Route {
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
|
Keeping route classes and route templates in their conventional files matches Ember's supported routing model and makes examples easier to apply in real apps.
|
||||||
|
|
||||||
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
|
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
---
|
||||||
|
name: nostr-zap-integration
|
||||||
|
description: Implement or debug NIP-57 zaps or NIP-61 nutzaps when the task involves zap requests, receipts, LNURL-pay, zap splits, Cashu tokens, or recipient payment configuration.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nostr Zap Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Build correct Lightning Zap and Nutzap flows for Nostr applications. This skill
|
||||||
|
covers the full NIP-57 lifecycle (LNURL discovery, zap request construction,
|
||||||
|
invoice handling, zap receipt validation) and the NIP-61 Cashu alternative
|
||||||
|
(nutzap configuration, P2PK token minting, nutzap publishing and redemption).
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- The task involves NIP-57 zaps or NIP-61 nutzaps in a Nostr application.
|
||||||
|
- The user needs zap requests, receipts, LNURL-pay integration, zap splits, recipient config, or Cashu token flow tied to Nostr events.
|
||||||
|
- The problem is payment interoperability between Nostr and Lightning/Cashu, not general wallet construction.
|
||||||
|
- The request is about end-to-end zap behavior or zap validation.
|
||||||
|
|
||||||
|
**Do NOT use when:**
|
||||||
|
|
||||||
|
- The task is generic Lightning, LNURL, or Cashu work with no Nostr zap context.
|
||||||
|
- The work is relay protocol or general Nostr event creation.
|
||||||
|
- The request is bech32 encoding or other peripheral concerns unrelated to zap flows.
|
||||||
|
|
||||||
|
|
||||||
|
## Response format
|
||||||
|
|
||||||
|
Always structure the final response with these top-level sections, in this order:
|
||||||
|
|
||||||
|
1. **Summary** — state the task, scope, and main conclusion in 1-3 sentences.
|
||||||
|
2. **Decision / Approach** — state the key classification, assumptions, or chosen path.
|
||||||
|
3. **Artifacts** — provide the primary deliverable(s) for this skill. Use clear subheadings for multiple files, commands, JSON payloads, queries, or documents.
|
||||||
|
4. **Validation** — state checks performed, important risks, caveats, or unresolved questions.
|
||||||
|
5. **Next steps** — list concrete follow-up actions, or write `None` if nothing remains.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not omit a section; write `None` when a section does not apply.
|
||||||
|
- If files are produced, list each file path under **Artifacts** before its contents.
|
||||||
|
- If commands, JSON, SQL, YAML, or code are produced, put each artifact in fenced code blocks with the correct language tag when possible.
|
||||||
|
- Keep section names exactly as written above so output stays predictable across skills.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Determine the Payment Path
|
||||||
|
|
||||||
|
Ask: "Is this a Lightning Zap (NIP-57) or a Nutzap (NIP-61)?"
|
||||||
|
|
||||||
|
| Path | When to Use | Key Kinds |
|
||||||
|
| -------------- | --------------------------------------- | ----------- |
|
||||||
|
| Lightning Zap | Recipient has lud16/lud06, LNURL server | 9734, 9735 |
|
||||||
|
| Nutzap (Cashu) | Recipient has kind:10019, trusted mints | 10019, 9321 |
|
||||||
|
|
||||||
|
If unsure, check the recipient's profile (kind:0) for `lud16`/`lud06` fields
|
||||||
|
(Lightning path) or query for their kind:10019 event (Nutzap path).
|
||||||
|
|
||||||
|
### 2. Lightning Zap Flow (NIP-57)
|
||||||
|
|
||||||
|
Follow the steps in [references/zap-flow.md](references/zap-flow.md) for the
|
||||||
|
complete implementation. Summary:
|
||||||
|
|
||||||
|
#### Step 2a: Discover the LNURL Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// From lud16 (e.g., "bob@example.com")
|
||||||
|
const [name, domain] = lud16.split("@");
|
||||||
|
const url = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const lnurlPayData = await res.json();
|
||||||
|
|
||||||
|
// Verify Nostr support
|
||||||
|
if (!lnurlPayData.allowsNostr || !lnurlPayData.nostrPubkey) {
|
||||||
|
throw new Error("Recipient does not support Nostr zaps");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical checks on the LNURL response:**
|
||||||
|
|
||||||
|
- `allowsNostr` MUST be `true`
|
||||||
|
- `nostrPubkey` MUST be a valid 32-byte hex public key
|
||||||
|
- Save `callback`, `minSendable`, `maxSendable` for later use
|
||||||
|
|
||||||
|
#### Step 2b: Construct the Zap Request (kind:9734)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 9734,
|
||||||
|
"content": "Optional zap comment",
|
||||||
|
"tags": [
|
||||||
|
["relays", "wss://relay1.example.com", "wss://relay2.example.com"],
|
||||||
|
["amount", "21000"],
|
||||||
|
["lnurl", "lnurl1dp68gurn8ghj7..."],
|
||||||
|
["p", "<recipient-pubkey-hex>"],
|
||||||
|
["e", "<event-id-hex>"],
|
||||||
|
["k", "<event-kind-string>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required tags:** `relays` (list of relay URLs), `p` (recipient pubkey).
|
||||||
|
**Recommended tags:** `amount` (millisats as string), `lnurl` (bech32-encoded).
|
||||||
|
**Optional tags:** `e` (event being zapped), `a` (addressable event coordinate),
|
||||||
|
`k` (kind of zapped event as string).
|
||||||
|
|
||||||
|
**Critical:** The zap request is NOT published to relays. It is sent to the
|
||||||
|
LNURL callback URL.
|
||||||
|
|
||||||
|
#### Step 2c: Send to Callback and Get Invoice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const zapRequestEncoded = encodeURIComponent(JSON.stringify(signedZapRequest));
|
||||||
|
const url =
|
||||||
|
`${callback}?amount=${amountMsats}&nostr=${zapRequestEncoded}&lnurl=${lnurlBech32}`;
|
||||||
|
const { pr: invoice } = await fetch(url).then((r) => r.json());
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2d: Pay the Invoice
|
||||||
|
|
||||||
|
Pass the bolt11 invoice to a Lightning wallet for payment. After payment, the
|
||||||
|
recipient's LNURL server creates and publishes the zap receipt (kind:9735).
|
||||||
|
|
||||||
|
#### Step 2e: Validate Zap Receipts
|
||||||
|
|
||||||
|
See [references/zap-flow.md](references/zap-flow.md) for full validation logic.
|
||||||
|
The three critical checks:
|
||||||
|
|
||||||
|
1. Receipt `pubkey` MUST match the recipient's LNURL `nostrPubkey`
|
||||||
|
2. Invoice amount in `bolt11` tag MUST match `amount` in the zap request
|
||||||
|
3. `SHA256(description)` SHOULD match the bolt11 description hash
|
||||||
|
|
||||||
|
### 3. Nutzap Flow (NIP-61)
|
||||||
|
|
||||||
|
Follow the steps in [references/nutzap-flow.md](references/nutzap-flow.md) for
|
||||||
|
the complete implementation. Summary:
|
||||||
|
|
||||||
|
#### Step 3a: Fetch Recipient's Nutzap Configuration (kind:10019)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 10019,
|
||||||
|
"tags": [
|
||||||
|
["relay", "wss://relay1.example.com"],
|
||||||
|
["relay", "wss://relay2.example.com"],
|
||||||
|
["mint", "https://mint.example.com", "sat"],
|
||||||
|
["mint", "https://othermint.example.com", "usd", "sat"],
|
||||||
|
["pubkey", "<p2pk-pubkey-hex>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** The `pubkey` tag value MUST NOT be the user's main Nostr pubkey.
|
||||||
|
It is a separate key used exclusively for P2PK locking.
|
||||||
|
|
||||||
|
#### Step 3b: Mint P2PK-Locked Tokens
|
||||||
|
|
||||||
|
1. Choose a mint from the recipient's `mint` tags
|
||||||
|
2. Mint or swap tokens P2PK-locked to the recipient's `pubkey` value
|
||||||
|
3. Prefix the pubkey with `"02"` for nostr-cashu compatibility
|
||||||
|
4. Include DLEQ proofs (NUT-12)
|
||||||
|
|
||||||
|
#### Step 3c: Publish the Nutzap (kind:9321)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 9321,
|
||||||
|
"content": "Optional comment",
|
||||||
|
"tags": [
|
||||||
|
["proof", "<cashu-proof-json>"],
|
||||||
|
["unit", "sat"],
|
||||||
|
["u", "https://mint.example.com"],
|
||||||
|
["e", "<zapped-event-id>", "<relay-hint>"],
|
||||||
|
["k", "<zapped-event-kind>"],
|
||||||
|
["p", "<recipient-nostr-pubkey>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish to the relays listed in the recipient's kind:10019 `relay` tags.
|
||||||
|
|
||||||
|
#### Step 3d: Receiving Nutzaps
|
||||||
|
|
||||||
|
Recipients query for kind:9321 events p-tagging them, filtered by trusted mint
|
||||||
|
URLs (`#u`). Upon receiving, swap the tokens into their wallet and publish a
|
||||||
|
kind:7376 redemption event.
|
||||||
|
|
||||||
|
### 4. Zap Splits
|
||||||
|
|
||||||
|
When an event has `zap` tags, distribute the zap across recipients:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["zap", "<pubkey>", "<relay>", "<weight>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Weights are relative. Calculate percentages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||||
|
for (const tag of zapTags) {
|
||||||
|
const weight = Number(tag[3] || 0);
|
||||||
|
const pct = weight / totalWeight;
|
||||||
|
const recipientAmount = Math.floor(totalAmount * pct);
|
||||||
|
// Create separate zap request for each recipient
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients without a weight value get weight 0 (no zap). If no weights are
|
||||||
|
present on any tag, divide equally.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Identified payment path (Lightning vs Nutzap)
|
||||||
|
- [ ] For Lightning: LNURL endpoint discovered and verified (`allowsNostr`,
|
||||||
|
`nostrPubkey`)
|
||||||
|
- [ ] For Lightning: Zap request (kind:9734) has required tags (`relays`, `p`)
|
||||||
|
- [ ] For Lightning: Zap request sent to callback URL, NOT published to relays
|
||||||
|
- [ ] For Lightning: Amount in millisats, within `minSendable`/`maxSendable`
|
||||||
|
- [ ] For Lightning: Zap receipt validation checks all three criteria
|
||||||
|
- [ ] For Nutzap: Recipient's kind:10019 fetched and parsed
|
||||||
|
- [ ] For Nutzap: Tokens minted at one of recipient's listed mints
|
||||||
|
- [ ] For Nutzap: P2PK pubkey prefixed with "02" and is NOT the main Nostr key
|
||||||
|
- [ ] For Nutzap: kind:9321 published to recipient's specified relays
|
||||||
|
- [ ] For splits: Weights calculated correctly, separate zap per recipient
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Why It Breaks | Fix |
|
||||||
|
| ---------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| Publishing kind:9734 to relays | Zap requests are sent to LNURL callback, never published | Send via HTTP GET to callback URL |
|
||||||
|
| Amount in satoshis instead of millisats | NIP-57 uses millisats (1 sat = 1000 msats) | Multiply sats by 1000 for the `amount` tag |
|
||||||
|
| Using recipient's Nostr pubkey for P2PK | NIP-61 requires a SEPARATE key for P2PK locking | Use the `pubkey` from kind:10019, never the main key |
|
||||||
|
| Missing `relays` tag on zap request | LNURL server won't know where to publish the receipt | Always include at least one relay in `relays` tag |
|
||||||
|
| Not validating receipt pubkey | Fake zap receipts from wrong keys accepted | Receipt pubkey MUST match LNURL `nostrPubkey` |
|
||||||
|
| Sending nutzap to unlisted mint | Recipient may never see it; tokens could be lost | Only use mints from recipient's kind:10019 `mint` tags |
|
||||||
|
| Missing "02" prefix on P2PK pubkey | Cashu P2PK expects compressed pubkey format | Always prefix with "02" for nostr-cashu compat |
|
||||||
|
| Not checking `allowsNostr` on LNURL | Server may not support Nostr zaps at all | Verify `allowsNostr: true` before constructing zap |
|
||||||
|
| Treating zap receipt as proof of payment | Receipts can be forged by rogue LNURL servers | Trust the receipt author, not the receipt itself |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Operation | Kind | Key Tags | Published? |
|
||||||
|
| ------------- | ----- | -------------------------------------- | --------------------- |
|
||||||
|
| Zap request | 9734 | `relays`, `p`, `amount`, `lnurl`, `e` | NO (HTTP only) |
|
||||||
|
| Zap receipt | 9735 | `p`, `P`, `bolt11`, `description`, `e` | YES (by LNURL server) |
|
||||||
|
| Nutzap config | 10019 | `relay`, `mint`, `pubkey` | YES (replaceable) |
|
||||||
|
| Nutzap send | 9321 | `proof`, `u`, `unit`, `p`, `e` | YES |
|
||||||
|
| Nutzap redeem | 7376 | `e` (9321 ref), `p` (sender) | YES (encrypted) |
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **Zap requests are HTTP-only** — Kind:9734 events are NEVER published to
|
||||||
|
relays. They are signed, JSON-encoded, URI-encoded, and sent as a query
|
||||||
|
parameter to the LNURL callback URL. This is the most common mistake.
|
||||||
|
|
||||||
|
2. **Validate the full chain** — A valid zap receipt requires matching the
|
||||||
|
receipt pubkey to the LNURL `nostrPubkey`, matching the invoice amount to the
|
||||||
|
request amount, and verifying the description hash. Skipping any check allows
|
||||||
|
forged zaps.
|
||||||
|
|
||||||
|
3. **Nutzap keys are separate** — The P2PK pubkey in kind:10019 MUST be a
|
||||||
|
different key from the user's main Nostr identity key. Using the same key
|
||||||
|
would allow anyone to spend received tokens. Always prefix with "02".
|
||||||
|
|
||||||
|
4. **Amounts are in millisatoshis** — NIP-57 uses millisats everywhere (1 sat =
|
||||||
|
1000 msats). The `amount` tag, `minSendable`, `maxSendable`, and invoice
|
||||||
|
amounts are all in millisats.
|
||||||
|
|
||||||
|
5. **Trust boundaries matter** — Zap receipts are NOT cryptographic proofs of
|
||||||
|
payment. They prove that a LNURL server claims payment was received. The
|
||||||
|
trust is in the LNURL server operator, not in the protocol itself.
|
||||||
|
|
||||||
|
## Optimization Notes
|
||||||
|
|
||||||
|
- Preserve the user's requested output shape exactly and do not substitute generic advice for concrete artifacts.
|
||||||
|
- Include exact commands, code structures, protocol fields, tags, parameters, file paths, or deliverable sections when the task asks for them.
|
||||||
|
- Make safety gates explicit before irreversible, destructive, externally visible, or compliance-sensitive actions.
|
||||||
|
- For multi-step work, present steps in execution order and include validation or rollback checks where relevant.
|
||||||
|
- Avoid overfitting to a single eval example: express lessons as reusable rules, not as task-specific answers.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "nostr-zap-integration",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "send-lightning-zap",
|
||||||
|
"prompt": "I'm building a Nostr client and need to implement sending a zap to a user. The recipient's profile has lud16 set to 'alice@getalby.com'. I want to zap 21 sats on their latest note (event id: 'abc123def456...', kind 1). My pubkey is '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322' and the recipient's pubkey is '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'. Show me the complete implementation including LNURL discovery, zap request construction, and sending to the callback.",
|
||||||
|
"expected_output": "A complete implementation showing: LNURL endpoint discovery from lud16, verification of allowsNostr and nostrPubkey, construction of a kind:9734 zap request with correct tags (relays, amount in millisats, p, e, lnurl), sending the signed event to the callback URL as a query parameter (NOT publishing to relays), and receiving the bolt11 invoice.",
|
||||||
|
"files": [],
|
||||||
|
"assertions": [
|
||||||
|
"Output contains 'kind: 9734' or 'kind:9734' or '9734' for the zap request event",
|
||||||
|
"Output contains 'allowsNostr' to check that the LNURL endpoint supports Nostr zaps",
|
||||||
|
"Output contains 'nostrPubkey' to verify the LNURL endpoint's signing key",
|
||||||
|
"Output contains '21000' (21 sats converted to millisats) in the amount tag",
|
||||||
|
"Output sends the zap request to the 'callback' URL (not publishing to relays)",
|
||||||
|
"The zap request event tags array has a 'relays' entry with relay URLs",
|
||||||
|
"Output sends zap request via HTTP GET to the callback URL with query parameters",
|
||||||
|
"Output constructs the LNURL discovery URL using '.well-known/lnurlp'"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "validate-zap-receipts",
|
||||||
|
"prompt": "I need to implement zap receipt validation for my Nostr client. When I fetch kind:9735 events, I need to verify they are legitimate. Write me a validation function that takes a zap receipt event and the expected LNURL nostrPubkey, and returns whether the zap is valid along with any errors. Cover all the validation checks from NIP-57 including pubkey matching, amount verification, and description hash checking. Also show me how to extract the sender pubkey and zap comment from a valid receipt.",
|
||||||
|
"expected_output": "A comprehensive validation function that checks: (1) receipt pubkey matches LNURL nostrPubkey, (2) parses the description tag as JSON to get the embedded zap request, (3) verifies the zap request is kind 9734, (4) checks invoice amount matches request amount, (5) verifies SHA256 of description matches bolt11 description hash. Also extracts sender pubkey from the embedded zap request's pubkey field and comment from its content field.",
|
||||||
|
"files": [],
|
||||||
|
"assertions": [
|
||||||
|
"Output works with 'kind: 9735' or 'kind:9735' zap receipt events",
|
||||||
|
"Output validates that the receipt pubkey matches the LNURL nostrPubkey",
|
||||||
|
"Output parses the 'description' tag as JSON to get the embedded zap request",
|
||||||
|
"Output checks the 'bolt11' tag for invoice amount verification",
|
||||||
|
"Output verifies amount in bolt11 matches amount in the zap request",
|
||||||
|
"Output contains 'SHA256' or 'sha256' for hashing the description to verify against bolt11 description hash",
|
||||||
|
"Output verifies the embedded zap request is kind 9734",
|
||||||
|
"Output extracts the sender pubkey from the embedded zap request event"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "nutzap-sending",
|
||||||
|
"prompt": "I want to implement Nutzap (NIP-61) sending in my Nostr client as an alternative to Lightning zaps. The recipient has published a kind:10019 event. Show me the complete flow: fetching their nutzap config, minting P2PK-locked Cashu tokens at one of their trusted mints, and publishing the kind:9321 nutzap event. Also explain how the recipient would verify and redeem the nutzap. Make sure to handle the P2PK pubkey correctly for nostr-cashu compatibility.",
|
||||||
|
"expected_output": "Complete implementation showing: (1) fetching and parsing kind:10019 with relay, mint, and pubkey tags, (2) verifying the P2PK pubkey is NOT the user's main Nostr key, (3) minting tokens P2PK-locked with '02' prefix on the pubkey, (4) constructing kind:9321 with proof, u, unit, p tags, (5) publishing to the relays from kind:10019, (6) recipient verification checking mint is trusted and proofs are locked to correct key, (7) redemption via token swap and kind:7376 event.",
|
||||||
|
"files": [],
|
||||||
|
"assertions": [
|
||||||
|
"Output fetches 'kind: 10019' or 'kind:10019' for the nutzap configuration",
|
||||||
|
"Output constructs 'kind: 9321' or 'kind:9321' nutzap events",
|
||||||
|
"Output contains 'P2PK' for locking the Cashu tokens",
|
||||||
|
"Output contains '02' prefix for the P2PK pubkey (compressed public key format)",
|
||||||
|
"Output warns that the P2PK pubkey must NOT be the user's main Nostr pubkey",
|
||||||
|
"The nutzap event has 'proof' tags carrying Cashu token data",
|
||||||
|
"The mint URL is fetched from the recipient's kind:10019 event",
|
||||||
|
"Output explains redemption including 'kind: 7376' or 'kind:7376' or token swap"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# NIP-61 Nutzap Flow
|
||||||
|
|
||||||
|
Complete step-by-step reference for implementing Cashu-based Nutzaps in Nostr
|
||||||
|
applications.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Nutzaps are an alternative to Lightning Zaps that use Cashu ecash tokens. The
|
||||||
|
payment itself is the receipt — no LNURL server needed. Tokens are P2PK-locked
|
||||||
|
to a recipient-specified public key and published as Nostr events.
|
||||||
|
|
||||||
|
## Protocol Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Sender Cashu Mint Relays
|
||||||
|
| | |
|
||||||
|
|-- Fetch kind:10019 -------|--------------------->|
|
||||||
|
|<-- {mints, pubkey, relays}| |
|
||||||
|
| | |
|
||||||
|
|-- Mint P2PK token ------->| |
|
||||||
|
|<-- {proofs} --------------| |
|
||||||
|
| | |
|
||||||
|
|-- Publish kind:9321 ------|--------------------->|
|
||||||
|
| | |
|
||||||
|
| Recipient |
|
||||||
|
| |-- Fetch kind:9321 ------>|
|
||||||
|
| |<-- {proofs} -------------|
|
||||||
|
| | |
|
||||||
|
| |-- Swap token ----------->|
|
||||||
|
| |<-- {new proofs} ---------|
|
||||||
|
| | |
|
||||||
|
| |-- Publish kind:7376 ---->|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Fetch Recipient's Configuration (kind:10019)
|
||||||
|
|
||||||
|
Query for the recipient's nutzap informational event:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const filter = {
|
||||||
|
kinds: [10019],
|
||||||
|
authors: [recipientPubkey],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parse the Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NutzapConfig {
|
||||||
|
relays: string[];
|
||||||
|
mints: { url: string; units: string[] }[];
|
||||||
|
p2pkPubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNutzapConfig(event: NostrEvent): NutzapConfig {
|
||||||
|
const relays = event.tags
|
||||||
|
.filter((t) => t[0] === "relay")
|
||||||
|
.map((t) => t[1]);
|
||||||
|
|
||||||
|
const mints = event.tags
|
||||||
|
.filter((t) => t[0] === "mint")
|
||||||
|
.map((t) => ({
|
||||||
|
url: t[1],
|
||||||
|
units: t.slice(2), // Additional elements are supported units
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pubkeyTag = event.tags.find((t) => t[0] === "pubkey");
|
||||||
|
if (!pubkeyTag) throw new Error("No pubkey tag in kind:10019");
|
||||||
|
|
||||||
|
return {
|
||||||
|
relays,
|
||||||
|
mints,
|
||||||
|
p2pkPubkey: pubkeyTag[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Checks
|
||||||
|
|
||||||
|
- At least one `relay` tag must be present
|
||||||
|
- At least one `mint` tag must be present
|
||||||
|
- `pubkey` tag MUST be present
|
||||||
|
- `pubkey` value MUST NOT equal the event's `.pubkey` (the user's main key)
|
||||||
|
- Mints SHOULD support NUT-11 (P2PK) and NUT-12 (DLEQ proofs)
|
||||||
|
|
||||||
|
## Step 2: Mint P2PK-Locked Tokens
|
||||||
|
|
||||||
|
### Choose a Mint
|
||||||
|
|
||||||
|
Select a mint from the recipient's `mint` tags. Check that it supports the
|
||||||
|
desired unit (e.g., "sat"):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function selectMint(config: NutzapConfig, unit: string = "sat"): string {
|
||||||
|
const compatible = config.mints.filter(
|
||||||
|
(m) => m.units.length === 0 || m.units.includes(unit),
|
||||||
|
);
|
||||||
|
if (compatible.length === 0) {
|
||||||
|
throw new Error(`No mints support unit: ${unit}`);
|
||||||
|
}
|
||||||
|
return compatible[0].url;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mint or Swap Tokens
|
||||||
|
|
||||||
|
Use a Cashu library (e.g., `@cashu/cashu-ts`) to mint tokens P2PK-locked to the
|
||||||
|
recipient's pubkey:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CashuMint, CashuWallet } from "@cashu/cashu-ts";
|
||||||
|
|
||||||
|
async function mintNutzapTokens(
|
||||||
|
mintUrl: string,
|
||||||
|
amountSats: number,
|
||||||
|
recipientP2pkPubkey: string,
|
||||||
|
): Promise<CashuProof[]> {
|
||||||
|
const mint = new CashuMint(mintUrl);
|
||||||
|
const wallet = new CashuWallet(mint);
|
||||||
|
|
||||||
|
// CRITICAL: Prefix pubkey with "02" for nostr<>cashu compatibility
|
||||||
|
const lockPubkey = recipientP2pkPubkey.startsWith("02")
|
||||||
|
? recipientP2pkPubkey
|
||||||
|
: `02${recipientP2pkPubkey}`;
|
||||||
|
|
||||||
|
// Mint tokens with P2PK lock
|
||||||
|
const { proofs } = await wallet.mintTokens(amountSats, {
|
||||||
|
p2pkPubkey: lockPubkey,
|
||||||
|
includeDleq: true, // NUT-12: Include DLEQ proofs
|
||||||
|
});
|
||||||
|
|
||||||
|
return proofs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical rules:**
|
||||||
|
|
||||||
|
- Always prefix the P2PK pubkey with `"02"` (compressed key format)
|
||||||
|
- Always request DLEQ proofs (NUT-12) for verifiability
|
||||||
|
- Only use mints listed in the recipient's kind:10019
|
||||||
|
- The mint URL in the nutzap MUST match EXACTLY as listed in kind:10019
|
||||||
|
|
||||||
|
## Step 3: Construct and Publish the Nutzap (kind:9321)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildNutzap(params: {
|
||||||
|
senderPubkey: string;
|
||||||
|
recipientPubkey: string; // Nostr identity pubkey, NOT P2PK key
|
||||||
|
proofs: CashuProof[];
|
||||||
|
mintUrl: string;
|
||||||
|
unit?: string;
|
||||||
|
eventId?: string;
|
||||||
|
eventKind?: number;
|
||||||
|
relayHint?: string;
|
||||||
|
comment?: string;
|
||||||
|
}): Omit<NostrEvent, "id" | "sig"> {
|
||||||
|
const tags: string[][] = [];
|
||||||
|
|
||||||
|
// Add each proof as a separate tag
|
||||||
|
for (const proof of params.proofs) {
|
||||||
|
tags.push(["proof", JSON.stringify(proof)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(["u", params.mintUrl]);
|
||||||
|
tags.push(["unit", params.unit || "sat"]);
|
||||||
|
tags.push(["p", params.recipientPubkey]);
|
||||||
|
|
||||||
|
if (params.eventId) {
|
||||||
|
const eTag = ["e", params.eventId];
|
||||||
|
if (params.relayHint) eTag.push(params.relayHint);
|
||||||
|
tags.push(eTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.eventKind !== undefined) {
|
||||||
|
tags.push(["k", params.eventKind.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 9321,
|
||||||
|
content: params.comment || "",
|
||||||
|
tags,
|
||||||
|
pubkey: params.senderPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag Reference for kind:9321
|
||||||
|
|
||||||
|
| Tag | Required | Format | Notes |
|
||||||
|
| ------- | -------- | -------------------------------- | ------------------------------ |
|
||||||
|
| `proof` | YES | `["proof", "<json>"]` | One or more proof tags |
|
||||||
|
| `u` | YES | `["u", "<mint-url>"]` | EXACT match to kind:10019 |
|
||||||
|
| `unit` | NO | `["unit", "sat"]` | Default: "sat" if omitted |
|
||||||
|
| `p` | YES | `["p", "<nostr-pubkey>"]` | Recipient's Nostr identity key |
|
||||||
|
| `e` | NO | `["e", "<event-id>", "<relay>"]` | Event being nutzapped |
|
||||||
|
| `k` | NO | `["k", "<kind>"]` | Kind of nutzapped event |
|
||||||
|
|
||||||
|
### Publish to Correct Relays
|
||||||
|
|
||||||
|
Publish the kind:9321 event to the relays listed in the recipient's kind:10019
|
||||||
|
`relay` tags. Failure to publish to these relays means the recipient may never
|
||||||
|
see the nutzap.
|
||||||
|
|
||||||
|
## Step 4: Receiving Nutzaps
|
||||||
|
|
||||||
|
### Query for Incoming Nutzaps
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildNutzapFilter(
|
||||||
|
myPubkey: string,
|
||||||
|
trustedMints: string[],
|
||||||
|
since?: number,
|
||||||
|
): NostrFilter {
|
||||||
|
return {
|
||||||
|
kinds: [9321],
|
||||||
|
"#p": [myPubkey],
|
||||||
|
"#u": trustedMints, // Only from mints we trust
|
||||||
|
...(since ? { since } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process and Redeem
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function redeemNutzap(
|
||||||
|
nutzapEvent: NostrEvent,
|
||||||
|
wallet: CashuWallet,
|
||||||
|
): Promise<CashuProof[]> {
|
||||||
|
// Extract proofs from the event
|
||||||
|
const proofTags = nutzapEvent.tags.filter((t) => t[0] === "proof");
|
||||||
|
const proofs = proofTags.map((t) => JSON.parse(t[1]));
|
||||||
|
|
||||||
|
// Verify mint URL matches a trusted mint
|
||||||
|
const mintUrl = nutzapEvent.tags.find((t) => t[0] === "u")?.[1];
|
||||||
|
if (!mintUrl) throw new Error("Missing mint URL");
|
||||||
|
|
||||||
|
// Swap tokens into our wallet (this claims them)
|
||||||
|
const newProofs = await wallet.receive(proofs);
|
||||||
|
|
||||||
|
return newProofs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record Redemption (kind:7376)
|
||||||
|
|
||||||
|
After successfully swapping tokens, publish a kind:7376 event to record the
|
||||||
|
redemption:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildRedemptionRecord(params: {
|
||||||
|
nutzapEventId: string;
|
||||||
|
nutzapRelayHint?: string;
|
||||||
|
senderPubkey: string;
|
||||||
|
amount: string;
|
||||||
|
unit: string;
|
||||||
|
newTokenEventId?: string;
|
||||||
|
newTokenRelayHint?: string;
|
||||||
|
}): Omit<NostrEvent, "id" | "sig" | "pubkey" | "created_at"> {
|
||||||
|
// Content is NIP-44 encrypted
|
||||||
|
const contentTags = [
|
||||||
|
["direction", "in"],
|
||||||
|
["amount", params.amount],
|
||||||
|
["unit", params.unit],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (params.newTokenEventId) {
|
||||||
|
const tag = ["e", params.newTokenEventId];
|
||||||
|
if (params.newTokenRelayHint) tag.push(params.newTokenRelayHint);
|
||||||
|
tag.push("created");
|
||||||
|
contentTags.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 7376,
|
||||||
|
content: nip44Encrypt(JSON.stringify(contentTags)), // NIP-44 encrypted
|
||||||
|
tags: [
|
||||||
|
["e", params.nutzapEventId, params.nutzapRelayHint || "", "redeemed"],
|
||||||
|
["p", params.senderPubkey],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Verifying Nutzaps (Observer)
|
||||||
|
|
||||||
|
Clients displaying nutzap counts or amounts should verify:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function verifyNutzap(
|
||||||
|
nutzap: NostrEvent,
|
||||||
|
recipientConfig: NostrEvent, // kind:10019
|
||||||
|
): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const config = parseNutzapConfig(recipientConfig);
|
||||||
|
|
||||||
|
// 1. Check mint is in recipient's trusted list
|
||||||
|
const mintUrl = nutzap.tags.find((t) => t[0] === "u")?.[1];
|
||||||
|
if (!mintUrl || !config.mints.some((m) => m.url === mintUrl)) {
|
||||||
|
errors.push("Mint not in recipient's trusted mint list");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check proofs are locked to the correct pubkey
|
||||||
|
const proofTags = nutzap.tags.filter((t) => t[0] === "proof");
|
||||||
|
for (const proofTag of proofTags) {
|
||||||
|
try {
|
||||||
|
const proof = JSON.parse(proofTag[1]);
|
||||||
|
const secret = JSON.parse(proof.secret);
|
||||||
|
if (secret[0] === "P2PK") {
|
||||||
|
const lockedTo = secret[1].data;
|
||||||
|
const expectedKey = config.p2pkPubkey.startsWith("02")
|
||||||
|
? config.p2pkPubkey
|
||||||
|
: `02${config.p2pkPubkey}`;
|
||||||
|
if (lockedTo !== expectedKey) {
|
||||||
|
errors.push("Proof not locked to recipient's P2PK pubkey");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.push("Invalid proof format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify DLEQ proofs (offline verification)
|
||||||
|
// This requires the mint's keyset - implementation depends on Cashu library
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All verification can be done offline (given the mint's keyset and the
|
||||||
|
recipient's kind:10019), making it fast and scalable.
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# NIP-57 Lightning Zap Flow
|
||||||
|
|
||||||
|
Complete step-by-step reference for implementing Lightning Zaps in Nostr
|
||||||
|
applications.
|
||||||
|
|
||||||
|
## Protocol Flow Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Sender Client LNURL Server Relays Lightning
|
||||||
|
| | | |
|
||||||
|
|-- GET lnurlp/user --->| | |
|
||||||
|
|<-- {allowsNostr, ...}-| | |
|
||||||
|
| | | |
|
||||||
|
|-- Sign kind:9734 ---->| | |
|
||||||
|
| (zap request) | | |
|
||||||
|
| | | |
|
||||||
|
|-- GET callback?nostr= | | |
|
||||||
|
|<-- {pr: bolt11} ------| | |
|
||||||
|
| | | |
|
||||||
|
|-- Pay invoice --------|-------------------|------> |
|
||||||
|
| | | |
|
||||||
|
| |-- kind:9735 ----->| |
|
||||||
|
| | (zap receipt) | |
|
||||||
|
| | | |
|
||||||
|
|<-- Fetch kind:9735 ---|-------------------| |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Discover the LNURL Endpoint
|
||||||
|
|
||||||
|
### From lud16 (Lightning Address)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getLnurlPayUrl(lud16: string): string {
|
||||||
|
const [name, domain] = lud16.split("@");
|
||||||
|
return `https://${domain}/.well-known/lnurlp/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: "bob@walletofsatoshi.com"
|
||||||
|
// → "https://walletofsatoshi.com/.well-known/lnurlp/bob"
|
||||||
|
```
|
||||||
|
|
||||||
|
### From zap tag on an event
|
||||||
|
|
||||||
|
If the event has `zap` tags, use those instead of the author's profile:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getZapRecipients(event: NostrEvent): ZapRecipient[] {
|
||||||
|
const zapTags = event.tags.filter((t) => t[0] === "zap");
|
||||||
|
if (zapTags.length === 0) return []; // Fall back to event author
|
||||||
|
|
||||||
|
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||||
|
|
||||||
|
return zapTags.map((tag) => ({
|
||||||
|
pubkey: tag[1],
|
||||||
|
relay: tag[2],
|
||||||
|
weight: Number(tag[3] || 0),
|
||||||
|
percentage: totalWeight > 0 ? Number(tag[3] || 0) / totalWeight : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Nostr Support
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LnurlPayResponse {
|
||||||
|
callback: string;
|
||||||
|
maxSendable: number; // millisats
|
||||||
|
minSendable: number; // millisats
|
||||||
|
metadata: string;
|
||||||
|
allowsNostr?: boolean;
|
||||||
|
nostrPubkey?: string; // 32-byte hex
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyNostrZapSupport(
|
||||||
|
lnurlPayUrl: string,
|
||||||
|
): Promise<LnurlPayResponse> {
|
||||||
|
const res = await fetch(lnurlPayUrl);
|
||||||
|
const data: LnurlPayResponse = await res.json();
|
||||||
|
|
||||||
|
if (!data.allowsNostr) {
|
||||||
|
throw new Error("LNURL endpoint does not support Nostr zaps");
|
||||||
|
}
|
||||||
|
if (!data.nostrPubkey || data.nostrPubkey.length !== 64) {
|
||||||
|
throw new Error("Invalid or missing nostrPubkey");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Construct the Zap Request (kind:9734)
|
||||||
|
|
||||||
|
### Required Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ZapRequest {
|
||||||
|
kind: 9734;
|
||||||
|
content: string; // Optional message
|
||||||
|
tags: string[][];
|
||||||
|
pubkey: string; // Sender's pubkey
|
||||||
|
created_at: number;
|
||||||
|
id: string;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZapRequest(params: {
|
||||||
|
senderPubkey: string;
|
||||||
|
recipientPubkey: string;
|
||||||
|
amountMsats: number;
|
||||||
|
relays: string[];
|
||||||
|
lnurl?: string;
|
||||||
|
eventId?: string;
|
||||||
|
eventKind?: number;
|
||||||
|
addressableCoord?: string; // "kind:pubkey:d-tag"
|
||||||
|
comment?: string;
|
||||||
|
}): Omit<ZapRequest, "id" | "sig"> {
|
||||||
|
const tags: string[][] = [
|
||||||
|
["relays", ...params.relays],
|
||||||
|
["amount", params.amountMsats.toString()],
|
||||||
|
["p", params.recipientPubkey],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (params.lnurl) tags.push(["lnurl", params.lnurl]);
|
||||||
|
if (params.eventId) tags.push(["e", params.eventId]);
|
||||||
|
if (params.addressableCoord) tags.push(["a", params.addressableCoord]);
|
||||||
|
if (params.eventKind !== undefined) {
|
||||||
|
tags.push(["k", params.eventKind.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 9734,
|
||||||
|
content: params.comment || "",
|
||||||
|
tags,
|
||||||
|
pubkey: params.senderPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag Reference
|
||||||
|
|
||||||
|
| Tag | Required | Format | Notes |
|
||||||
|
| -------- | ----------- | ------------------------------------ | --------------------- |
|
||||||
|
| `relays` | YES | `["relays", "wss://r1", "wss://r2"]` | NOT nested arrays |
|
||||||
|
| `p` | YES | `["p", "<hex-pubkey>"]` | Exactly one |
|
||||||
|
| `amount` | Recommended | `["amount", "21000"]` | Millisats as string |
|
||||||
|
| `lnurl` | Recommended | `["lnurl", "lnurl1..."]` | Bech32-encoded |
|
||||||
|
| `e` | Optional | `["e", "<hex-event-id>"]` | When zapping an event |
|
||||||
|
| `a` | Optional | `["a", "30023:pubkey:d-tag"]` | Addressable events |
|
||||||
|
| `k` | Optional | `["k", "1"]` | Kind of zapped event |
|
||||||
|
|
||||||
|
### Validation Rules for Zap Requests
|
||||||
|
|
||||||
|
The LNURL server validates incoming zap requests:
|
||||||
|
|
||||||
|
1. Valid Nostr signature
|
||||||
|
2. Has tags
|
||||||
|
3. Exactly one `p` tag
|
||||||
|
4. Zero or one `e` tags
|
||||||
|
5. Has a `relays` tag
|
||||||
|
6. If `amount` tag exists, it MUST equal the `amount` query parameter
|
||||||
|
7. If `a` tag exists, it MUST be a valid event coordinate
|
||||||
|
8. Zero or one `P` tags
|
||||||
|
|
||||||
|
## Step 3: Send to Callback URL
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function requestInvoice(
|
||||||
|
callback: string,
|
||||||
|
signedZapRequest: ZapRequest,
|
||||||
|
amountMsats: number,
|
||||||
|
lnurl?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
amount: amountMsats.toString(),
|
||||||
|
nostr: JSON.stringify(signedZapRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lnurl) params.set("lnurl", lnurl);
|
||||||
|
|
||||||
|
const res = await fetch(`${callback}?${params.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === "ERROR") {
|
||||||
|
throw new Error(`LNURL error: ${data.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.pr; // bolt11 invoice
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** The zap request is JSON-encoded, then sent as a query parameter.
|
||||||
|
It is NOT published to any relay.
|
||||||
|
|
||||||
|
## Step 4: Pay the Invoice
|
||||||
|
|
||||||
|
Pass the bolt11 invoice string to a Lightning wallet or payment library. This
|
||||||
|
step is outside the Nostr protocol — use whatever Lightning integration your
|
||||||
|
application supports (WebLN, NWC, direct LND/CLN API, etc.).
|
||||||
|
|
||||||
|
## Step 5: Zap Receipt Creation (Server-Side)
|
||||||
|
|
||||||
|
After the invoice is paid, the recipient's LNURL server creates a kind:9735
|
||||||
|
event:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildZapReceipt(params: {
|
||||||
|
serverPubkey: string; // The LNURL server's nostrPubkey
|
||||||
|
zapRequest: ZapRequest;
|
||||||
|
bolt11: string;
|
||||||
|
preimage?: string;
|
||||||
|
paidAt: number;
|
||||||
|
}): Omit<NostrEvent, "id" | "sig"> {
|
||||||
|
const zapReq = params.zapRequest;
|
||||||
|
const recipientPubkey = zapReq.tags.find((t) => t[0] === "p")?.[1];
|
||||||
|
const senderPubkey = zapReq.pubkey;
|
||||||
|
const eventId = zapReq.tags.find((t) => t[0] === "e")?.[1];
|
||||||
|
const aTag = zapReq.tags.find((t) => t[0] === "a");
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
["p", recipientPubkey!],
|
||||||
|
["P", senderPubkey],
|
||||||
|
["bolt11", params.bolt11],
|
||||||
|
["description", JSON.stringify(zapReq)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (eventId) tags.push(["e", eventId]);
|
||||||
|
if (aTag) tags.push(aTag);
|
||||||
|
if (params.preimage) tags.push(["preimage", params.preimage]);
|
||||||
|
|
||||||
|
// Amount from the zap request
|
||||||
|
const amount = zapReq.tags.find((t) => t[0] === "amount")?.[1];
|
||||||
|
if (amount) tags.push(["amount", amount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 9735,
|
||||||
|
content: "",
|
||||||
|
tags,
|
||||||
|
pubkey: params.serverPubkey,
|
||||||
|
created_at: params.paidAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The receipt is published to the relays specified in the zap request's `relays`
|
||||||
|
tag.
|
||||||
|
|
||||||
|
## Step 6: Validate Zap Receipts (Client-Side)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ZapValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
zapRequest?: ZapRequest;
|
||||||
|
amountMsats?: number;
|
||||||
|
senderPubkey?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateZapReceipt(
|
||||||
|
receipt: NostrEvent,
|
||||||
|
expectedNostrPubkey: string,
|
||||||
|
): ZapValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Check receipt pubkey matches LNURL nostrPubkey
|
||||||
|
if (receipt.pubkey !== expectedNostrPubkey) {
|
||||||
|
errors.push(
|
||||||
|
`Receipt pubkey ${receipt.pubkey} does not match expected ${expectedNostrPubkey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse the embedded zap request
|
||||||
|
const descriptionTag = receipt.tags.find((t) => t[0] === "description");
|
||||||
|
if (!descriptionTag) {
|
||||||
|
errors.push("Missing description tag");
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
let zapRequest: ZapRequest;
|
||||||
|
try {
|
||||||
|
zapRequest = JSON.parse(descriptionTag[1]);
|
||||||
|
} catch {
|
||||||
|
errors.push("Invalid JSON in description tag");
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zapRequest.kind !== 9734) {
|
||||||
|
errors.push(`Zap request kind is ${zapRequest.kind}, expected 9734`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify amount matches
|
||||||
|
const bolt11Tag = receipt.tags.find((t) => t[0] === "bolt11");
|
||||||
|
const requestAmount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
|
||||||
|
|
||||||
|
if (bolt11Tag && requestAmount) {
|
||||||
|
const invoiceAmount = decodeBolt11Amount(bolt11Tag[1]);
|
||||||
|
if (invoiceAmount !== Number(requestAmount)) {
|
||||||
|
errors.push(
|
||||||
|
`Invoice amount ${invoiceAmount} does not match request amount ${requestAmount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify description hash (SHOULD check)
|
||||||
|
if (bolt11Tag) {
|
||||||
|
const descHash = sha256(descriptionTag[1]);
|
||||||
|
const bolt11DescHash = extractDescriptionHash(bolt11Tag[1]);
|
||||||
|
if (bolt11DescHash && descHash !== bolt11DescHash) {
|
||||||
|
errors.push("Description hash mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
zapRequest,
|
||||||
|
amountMsats: requestAmount ? Number(requestAmount) : undefined,
|
||||||
|
senderPubkey: zapRequest.pubkey,
|
||||||
|
comment: zapRequest.content || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetching Zap Receipts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Zaps on a specific event
|
||||||
|
const filter = { kinds: [9735], "#e": [eventId] };
|
||||||
|
|
||||||
|
// Zaps on a user profile
|
||||||
|
const filter = { kinds: [9735], "#p": [pubkey] };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zap Splits Implementation
|
||||||
|
|
||||||
|
When an event has multiple `zap` tags:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateZapSplits(
|
||||||
|
event: NostrEvent,
|
||||||
|
totalAmountMsats: number,
|
||||||
|
): { pubkey: string; relay: string; amountMsats: number }[] {
|
||||||
|
const zapTags = event.tags.filter((t) => t[0] === "zap");
|
||||||
|
if (zapTags.length === 0) return [];
|
||||||
|
|
||||||
|
const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0);
|
||||||
|
|
||||||
|
if (totalWeight === 0) {
|
||||||
|
// No weights: divide equally
|
||||||
|
const perRecipient = Math.floor(totalAmountMsats / zapTags.length);
|
||||||
|
return zapTags.map((t) => ({
|
||||||
|
pubkey: t[1],
|
||||||
|
relay: t[2],
|
||||||
|
amountMsats: perRecipient,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return zapTags
|
||||||
|
.filter((t) => Number(t[3] || 0) > 0) // Skip zero-weight recipients
|
||||||
|
.map((t) => ({
|
||||||
|
pubkey: t[1],
|
||||||
|
relay: t[2],
|
||||||
|
amountMsats: Math.floor(totalAmountMsats * Number(t[3]) / totalWeight),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each split recipient gets a separate zap request → callback → invoice → payment
|
||||||
|
cycle.
|
||||||
@@ -20,13 +20,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Lint
|
- name: Lint
|
||||||
@@ -42,9 +39,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
|
|||||||
@@ -9,17 +9,72 @@ 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 { and } from 'ember-truth-helpers';
|
||||||
import cachedImage from '../modifiers/cached-image';
|
import cachedImage from '../modifiers/cached-image';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service settings;
|
@service settings;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service mapUi;
|
||||||
|
@service router;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
@tracked searchQuery = '';
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
get hasQuery() {
|
constructor() {
|
||||||
return !!this.searchQuery;
|
super(...arguments);
|
||||||
|
if (this.router && typeof this.router.on === 'function') {
|
||||||
|
this.router.on('routeDidChange', this.syncSearchQuery);
|
||||||
|
}
|
||||||
|
this.syncSearchQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
if (this.router && typeof this.router.off === 'function') {
|
||||||
|
this.router.off('routeDidChange', this.syncSearchQuery);
|
||||||
|
}
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
syncSearchQuery() {
|
||||||
|
const qp =
|
||||||
|
this.mapUi.currentSearch || this.router?.currentRoute?.queryParams;
|
||||||
|
if (qp?.q) {
|
||||||
|
this.searchQuery = qp.q;
|
||||||
|
} else if (qp?.category) {
|
||||||
|
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||||
|
this.searchQuery = category ? category.label : qp.category;
|
||||||
|
} else {
|
||||||
|
this.searchQuery = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSearching() {
|
||||||
|
// 1. If we are actively focusing/typing in the search box with a query, hide pills
|
||||||
|
if (this.mapUi.searchBoxHasFocus && this.searchQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If we are on the search route, check loading and results status
|
||||||
|
if (this.router?.currentRouteName === 'search') {
|
||||||
|
if (this.mapUi.loadingState) {
|
||||||
|
return false; // Keep pills visible while loading
|
||||||
|
}
|
||||||
|
return this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback for integration tests (non-search route with a query)
|
||||||
|
if (this.router?.currentRouteName !== 'search' && this.searchQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showQuickSearch() {
|
||||||
|
const zoom = this.mapUi.currentZoom ?? 13;
|
||||||
|
return this.settings.showQuickSearchButtons && zoom >= 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -54,8 +109,8 @@ export default class AppHeaderComponent extends Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.settings.showQuickSearchButtons}}
|
{{#if this.showQuickSearch}}
|
||||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
<div class="header-center {{if this.isSearching 'searching'}}">
|
||||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import Icon from '#components/icon';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{! template-lint-disable no-nested-interactive }}
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header has-back-btn">
|
||||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
<h2>About</h2>
|
<h2 class="sidebar-header-text-centered">About</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
@@ -34,6 +34,15 @@ import Icon from '#components/icon';
|
|||||||
>remote storage</a>
|
>remote storage</a>
|
||||||
to sync place bookmarks across apps and devices.
|
to sync place bookmarks across apps and devices.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Connect your
|
||||||
|
<a
|
||||||
|
href="https://start.nostr.net"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Nostr</a>
|
||||||
|
identity to publish photos of places.
|
||||||
|
</p>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -91,6 +100,26 @@ import Icon from '#components/icon';
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://openfreemap.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Map tiles
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://github.com/hyperknot/openfreemap/blob/main/LICENSE.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Various
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
@@ -139,13 +168,20 @@ import Icon from '#components/icon';
|
|||||||
<div class="details-content">
|
<div class="details-content">
|
||||||
<p>
|
<p>
|
||||||
<strong>Most impactful:</strong>
|
<strong>Most impactful:</strong>
|
||||||
Add and improve data for points of interest in
|
|
||||||
<a
|
|
||||||
href="https://www.openstreetmap.org"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>OpenStreetMap</a>.
|
|
||||||
</p>
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Add and improve data for points of interest in
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>OpenStreetMap</a>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Add place photos with your Nostr account.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
<strong>Most appreciated:</strong>
|
<strong>Most appreciated:</strong>
|
||||||
Use this app as much as you can and
|
Use this app as much as you can and
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
|||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<ul class="app-menu">
|
<ul class="app-menu">
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" @onSavedPlaces}}>
|
||||||
|
<Icon @name="bookmark" @size={{20}} />
|
||||||
|
<span>Collections</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" @onContributions}}>
|
||||||
|
<Icon @name="activity" @size={{20}} />
|
||||||
|
<span>My Contributions</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||||
<Icon @name="settings" @size={{20}} />
|
<Icon @name="settings" @size={{20}} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
|
import { service } from '@ember/service';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
import AppMenuHome from './home';
|
import AppMenuHome from './home';
|
||||||
@@ -9,6 +10,7 @@ import AppMenuSettings from './settings';
|
|||||||
import AppMenuAbout from './about';
|
import AppMenuAbout from './about';
|
||||||
|
|
||||||
export default class AppMenu extends Component {
|
export default class AppMenu extends Component {
|
||||||
|
@service router;
|
||||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -16,10 +18,25 @@ export default class AppMenu extends Component {
|
|||||||
this.currentView = view;
|
this.currentView = view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
goToSavedPlaces() {
|
||||||
|
this.router.transitionTo('lists.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
goToContributions() {
|
||||||
|
this.router.transitionTo('contributions');
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar app-menu-pane">
|
<div class="sidebar app-menu-pane">
|
||||||
{{#if (eq this.currentView "menu")}}
|
{{#if (eq this.currentView "menu")}}
|
||||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
<AppMenuHome
|
||||||
|
@onNavigate={{this.setView}}
|
||||||
|
@onClose={{@onClose}}
|
||||||
|
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||||
|
@onContributions={{this.goToContributions}}
|
||||||
|
/>
|
||||||
|
|
||||||
{{else if (eq this.currentView "settings")}}
|
{{else if (eq this.currentView "settings")}}
|
||||||
<AppMenuSettings
|
<AppMenuSettings
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Icon from '#components/icon';
|
|||||||
import AppMenuSettingsMapUi from './settings/map-ui';
|
import AppMenuSettingsMapUi from './settings/map-ui';
|
||||||
import AppMenuSettingsApis from './settings/apis';
|
import AppMenuSettingsApis from './settings/apis';
|
||||||
import AppMenuSettingsNostr from './settings/nostr';
|
import AppMenuSettingsNostr from './settings/nostr';
|
||||||
|
import AppMenuSettingsExperimental from './settings/experimental';
|
||||||
|
|
||||||
export default class AppMenuSettings extends Component {
|
export default class AppMenuSettings extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -20,11 +21,11 @@ export default class AppMenuSettings extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header has-back-btn">
|
||||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
<h2>Settings</h2>
|
<h2 class="sidebar-header-text-centered">Settings</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
@@ -35,6 +36,7 @@ export default class AppMenuSettings extends Component {
|
|||||||
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||||
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||||
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||||
|
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default class AppMenuSettingsApis extends Component {
|
|||||||
<Icon @name="server" @size={{20}} />
|
<Icon @name="server" @size={{20}} />
|
||||||
<span>API Providers</span>
|
<span>API Providers</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="details-content">
|
<div class="details-content form-layout">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="overpass-api">Overpass API Provider</label>
|
<label for="overpass-api">Overpass API Provider</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 AppMenuSettingsExperimental extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="alert-triangle" @size={{20}} />
|
||||||
|
<span>Experimental</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="experimental-enable-photo-deletion">Enable photo deletion
|
||||||
|
(own photos)</label>
|
||||||
|
<select
|
||||||
|
id="experimental-enable-photo-deletion"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "experimentalEnablePhotoDeletion")}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if
|
||||||
|
this.settings.experimentalEnablePhotoDeletion
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless
|
||||||
|
this.settings.experimentalEnablePhotoDeletion
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export default class AppMenuSettingsMapUi extends Component {
|
|||||||
<Icon @name="map" @size={{20}} />
|
<Icon @name="map" @size={{20}} />
|
||||||
<span>Map & UI</span>
|
<span>Map & UI</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="details-content">
|
<div class="details-content form-layout">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="show-quick-search">Quick search buttons visible</label>
|
<label for="show-quick-search">Quick search buttons visible</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -5,17 +5,90 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { normalizeRelayUrl } from '../../../utils/nostr';
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
} from '../../../utils/nostr';
|
||||||
|
|
||||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||||
|
|
||||||
export default class AppMenuSettingsNostr extends Component {
|
export default class AppMenuSettingsNostr extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
@tracked newReadRelay = '';
|
@tracked newReadRelay = '';
|
||||||
@tracked newWriteRelay = '';
|
@tracked newWriteRelay = '';
|
||||||
|
|
||||||
|
get customReadRelays() {
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
this.settings.nostrReadRelays || [],
|
||||||
|
this.nostrData.requiredReadRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get customWriteRelays() {
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
this.settings.nostrWriteRelays || [],
|
||||||
|
this.nostrData.requiredWriteRelays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readRelayExclusions() {
|
||||||
|
return this.settings.nostrReadRelayExclusions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get writeRelayExclusions() {
|
||||||
|
return this.settings.nostrWriteRelayExclusions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get requiredReadRelaySet() {
|
||||||
|
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
get requiredWriteRelaySet() {
|
||||||
|
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxReadRelaySet() {
|
||||||
|
return new Set(this.nostrData.mailboxReadRelays);
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxWriteRelaySet() {
|
||||||
|
return new Set(this.nostrData.mailboxWriteRelays);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasReadOverrides() {
|
||||||
|
return (
|
||||||
|
this.customReadRelays.length > 0 || this.readRelayExclusions.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasWriteOverrides() {
|
||||||
|
return (
|
||||||
|
this.customWriteRelays.length > 0 || this.writeRelayExclusions.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readRelaysForDisplay() {
|
||||||
|
return this.nostrData.activeReadRelays.map((url) => {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isRequired: this.requiredReadRelaySet.has(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get writeRelaysForDisplay() {
|
||||||
|
return this.nostrData.activeWriteRelays.map((url) => {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isRequired: this.requiredWriteRelaySet.has(url),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateNewReadRelay(event) {
|
updateNewReadRelay(event) {
|
||||||
this.newReadRelay = event.target.value;
|
this.newReadRelay = event.target.value;
|
||||||
@@ -31,19 +104,51 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
const url = normalizeRelayUrl(this.newReadRelay);
|
const url = normalizeRelayUrl(this.newReadRelay);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const current =
|
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [
|
||||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
...this.customReadRelays,
|
||||||
const set = new Set([...current, url]);
|
url,
|
||||||
this.settings.update('nostrReadRelays', Array.from(set));
|
]);
|
||||||
|
const custom = excludeRequiredRelays(
|
||||||
|
merged,
|
||||||
|
this.nostrData.requiredReadRelays
|
||||||
|
);
|
||||||
|
|
||||||
|
const readExclusions = this.readRelayExclusions.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== url;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.update('nostrReadRelays', custom.length > 0 ? custom : null);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelayExclusions',
|
||||||
|
readExclusions.length > 0 ? readExclusions : null
|
||||||
|
);
|
||||||
this.newReadRelay = '';
|
this.newReadRelay = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeReadRelay(url) {
|
removeReadRelay(url) {
|
||||||
const current =
|
if (this.requiredReadRelaySet.has(url)) {
|
||||||
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
return;
|
||||||
const filtered = current.filter((r) => r !== url);
|
}
|
||||||
this.settings.update('nostrReadRelays', filtered);
|
|
||||||
|
const normalizedUrl = normalizeRelayUrl(url);
|
||||||
|
|
||||||
|
const remainingCustom = this.customReadRelays.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextExclusions = this.mailboxReadRelaySet.has(normalizedUrl)
|
||||||
|
? Array.from(new Set([...this.readRelayExclusions, normalizedUrl]))
|
||||||
|
: this.readRelayExclusions;
|
||||||
|
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelays',
|
||||||
|
remainingCustom.length > 0 ? remainingCustom : null
|
||||||
|
);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrReadRelayExclusions',
|
||||||
|
nextExclusions.length > 0 ? nextExclusions : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -63,6 +168,7 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
@action
|
@action
|
||||||
resetReadRelays() {
|
resetReadRelays() {
|
||||||
this.settings.update('nostrReadRelays', null);
|
this.settings.update('nostrReadRelays', null);
|
||||||
|
this.settings.update('nostrReadRelayExclusions', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -70,24 +176,67 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const current =
|
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [
|
||||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
...this.customWriteRelays,
|
||||||
const set = new Set([...current, url]);
|
url,
|
||||||
this.settings.update('nostrWriteRelays', Array.from(set));
|
]);
|
||||||
|
const custom = excludeRequiredRelays(
|
||||||
|
merged,
|
||||||
|
this.nostrData.requiredWriteRelays
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeExclusions = this.writeRelayExclusions.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== url;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.update('nostrWriteRelays', custom.length > 0 ? custom : null);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelayExclusions',
|
||||||
|
writeExclusions.length > 0 ? writeExclusions : null
|
||||||
|
);
|
||||||
this.newWriteRelay = '';
|
this.newWriteRelay = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeWriteRelay(url) {
|
removeWriteRelay(url) {
|
||||||
const current =
|
if (this.requiredWriteRelaySet.has(url)) {
|
||||||
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
return;
|
||||||
const filtered = current.filter((r) => r !== url);
|
}
|
||||||
this.settings.update('nostrWriteRelays', filtered);
|
|
||||||
|
const normalizedUrl = normalizeRelayUrl(url);
|
||||||
|
|
||||||
|
const remainingCustom = this.customWriteRelays.filter((relay) => {
|
||||||
|
return normalizeRelayUrl(relay) !== normalizedUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextExclusions = this.mailboxWriteRelaySet.has(normalizedUrl)
|
||||||
|
? Array.from(new Set([...this.writeRelayExclusions, normalizedUrl]))
|
||||||
|
: this.writeRelayExclusions;
|
||||||
|
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelays',
|
||||||
|
remainingCustom.length > 0 ? remainingCustom : null
|
||||||
|
);
|
||||||
|
this.settings.update(
|
||||||
|
'nostrWriteRelayExclusions',
|
||||||
|
nextExclusions.length > 0 ? nextExclusions : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
resetWriteRelays() {
|
resetWriteRelays() {
|
||||||
this.settings.update('nostrWriteRelays', null);
|
this.settings.update('nostrWriteRelays', null);
|
||||||
|
this.settings.update('nostrWriteRelayExclusions', 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>
|
||||||
@@ -97,7 +246,101 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
<Icon @name="zap" @size={{20}} />
|
<Icon @name="zap" @size={{20}} />
|
||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="details-content">
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-read-relay">Read Relays</label>
|
||||||
|
<ul class="relay-list">
|
||||||
|
{{#each this.readRelaysForDisplay as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay.url}}</span>
|
||||||
|
{{#unless relay.isRequired}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{/unless}}
|
||||||
|
</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.hasReadOverrides}}
|
||||||
|
<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.writeRelaysForDisplay as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay.url}}</span>
|
||||||
|
{{#unless relay.isRequired}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{/unless}}
|
||||||
|
</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.hasWriteOverrides}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetWriteRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||||
servers</label>
|
servers</label>
|
||||||
@@ -125,93 +368,15 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new-read-relay">Read Relays</label>
|
<label>Cached data</label>
|
||||||
<ul class="relay-list">
|
<button
|
||||||
{{#each this.nostrData.activeReadRelays as |relay|}}
|
type="button"
|
||||||
<li>
|
class="btn btn-outline btn-full"
|
||||||
<span>{{stripProtocol relay}}</span>
|
{{on "click" this.clearCache}}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
|
||||||
class="btn-remove-relay"
|
Clear profiles, photos, and reviews
|
||||||
title="Remove relay"
|
</button>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
|
import formatRelativeDate from '../helpers/format-relative-date';
|
||||||
|
|
||||||
|
export default class ContributionPhoto extends Component {
|
||||||
|
get item() {
|
||||||
|
return this.args.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
get primaryPhoto() {
|
||||||
|
return this.item?.photos?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get extraPhotoCount() {
|
||||||
|
return Math.max(0, (this.item?.photos?.length || 0) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeName() {
|
||||||
|
return this.item?.placeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isNameLoading() {
|
||||||
|
return this.item?.placeNameLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tags() {
|
||||||
|
// Collect unique tags across all photos in the contribution group
|
||||||
|
const all = (this.item?.photos || []).flatMap((p) => p.tags || []);
|
||||||
|
return [...new Set(all)];
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contribution-item"
|
||||||
|
{{on "click" (fn @onSelect @item)}}
|
||||||
|
>
|
||||||
|
<div class="contribution-thumb">
|
||||||
|
{{#if this.primaryPhoto}}
|
||||||
|
<img
|
||||||
|
src={{or this.primaryPhoto.thumbUrl this.primaryPhoto.url}}
|
||||||
|
alt={{or this.primaryPhoto.alt "Place photo"}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{#if (gt this.extraPhotoCount 0)}}
|
||||||
|
<span
|
||||||
|
class="contribution-thumb-badge"
|
||||||
|
>+{{this.extraPhotoCount}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="contribution-info">
|
||||||
|
<div class="contribution-place">
|
||||||
|
{{#if this.placeName}}
|
||||||
|
{{this.placeName}}
|
||||||
|
{{else if this.isNameLoading}}
|
||||||
|
<span class="contribution-name-loading">Loading…</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="contribution-name-loading">Unnamed place</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="contribution-meta">
|
||||||
|
{{formatRelativeDate @item.createdAt}}
|
||||||
|
{{#if (gt this.item.photos.length 1)}}
|
||||||
|
·
|
||||||
|
{{this.item.photos.length}}
|
||||||
|
photos
|
||||||
|
{{else}}
|
||||||
|
· 1 photo
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{#if this.tags.length}}
|
||||||
|
<div class="contribution-tags">
|
||||||
|
{{#each this.tags as |tag|}}
|
||||||
|
<span class="contribution-tag">{{tag}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from './icon';
|
||||||
|
import ContributionPhoto from './contribution-photo';
|
||||||
|
import Modal from './modal';
|
||||||
|
import NostrConnect from './nostr-connect';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
import not from 'ember-truth-helpers/helpers/not';
|
||||||
|
import restoreScroll from '../modifiers/restore-scroll';
|
||||||
|
|
||||||
|
export default class ContributionsTimelineComponent extends Component {
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
openNostrConnectModal(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onNostrConnected() {
|
||||||
|
this.closeNostrConnectModal();
|
||||||
|
this.args.onNostrConnected?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header has-back-btn">
|
||||||
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2 class="sidebar-header-text-centered">
|
||||||
|
<span class="sidebar-header-icon-wrapper">
|
||||||
|
<Icon @name="activity" @size={{20}} @color="#898989" />
|
||||||
|
</span>
|
||||||
|
My Contributions
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||||
|
{{#if @isLoading}}
|
||||||
|
<div class="sidebar-loading">
|
||||||
|
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||||
|
</div>
|
||||||
|
{{else if (not @isConnected)}}
|
||||||
|
<p class="empty-state">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
{{on "click" this.openNostrConnectModal}}
|
||||||
|
>Connect your Nostr account</a>
|
||||||
|
to see your contributions.
|
||||||
|
</p>
|
||||||
|
{{else if (not @items.length)}}
|
||||||
|
<p class="empty-state">No contributions yet. Start by adding photos to
|
||||||
|
places.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="contributions-list">
|
||||||
|
{{#each @items as |item|}}
|
||||||
|
{{#if (eq item.type "photo")}}
|
||||||
|
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
export default class DropdownMenu extends Component {
|
||||||
|
@tracked isOpen = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleMenu(e) {
|
||||||
|
e?.stopPropagation();
|
||||||
|
this.isOpen = !this.isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeMenu(e) {
|
||||||
|
e?.stopPropagation();
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get triggerIcon() {
|
||||||
|
return this.args.triggerIcon || 'more-vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dropdown-menu-container">
|
||||||
|
<button
|
||||||
|
class="dropdown-trigger-btn btn-press"
|
||||||
|
type="button"
|
||||||
|
title={{@triggerTitle}}
|
||||||
|
{{on "click" this.toggleMenu}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
@name={{this.triggerIcon}}
|
||||||
|
@size={{@iconSize}}
|
||||||
|
@color={{@iconColor}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{#if this.isOpen}}
|
||||||
|
<div class="dropdown-popover {{@popoverClass}}">
|
||||||
|
{{yield this.closeMenu}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="menu-backdrop"
|
||||||
|
{{on "click" this.closeMenu}}
|
||||||
|
role="button"
|
||||||
|
></div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
+49
-11
@@ -8,6 +8,7 @@ import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
|||||||
import Kinetic from 'ol/Kinetic.js';
|
import Kinetic from 'ol/Kinetic.js';
|
||||||
import View from 'ol/View.js';
|
import View from 'ol/View.js';
|
||||||
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||||
|
import { containsExtent } from 'ol/extent.js';
|
||||||
import Overlay from 'ol/Overlay.js';
|
import Overlay from 'ol/Overlay.js';
|
||||||
import LayerGroup from 'ol/layer/Group.js';
|
import LayerGroup from 'ol/layer/Group.js';
|
||||||
import VectorLayer from 'ol/layer/Vector.js';
|
import VectorLayer from 'ol/layer/Vector.js';
|
||||||
@@ -284,6 +285,7 @@ export default class MapComponent extends Component {
|
|||||||
// Initialize the UI service with the map center
|
// Initialize the UI service with the map center
|
||||||
const initialCenter = toLonLat(view.getCenter());
|
const initialCenter = toLonLat(view.getCenter());
|
||||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||||
|
this.mapUi.updateZoom(view.getZoom());
|
||||||
|
|
||||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||||
webfonts: 'data:text/css,',
|
webfonts: 'data:text/css,',
|
||||||
@@ -664,7 +666,12 @@ export default class MapComponent extends Component {
|
|||||||
if (options.preventZoom) {
|
if (options.preventZoom) {
|
||||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||||
// This avoids unnecessary panning if the place is already visible.
|
// This avoids unnecessary panning if the place is already visible.
|
||||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
// But if the place has a bbox that doesn't fit in the current view, zoom out to fit it.
|
||||||
|
if (selected.bbox && !this.bboxFitsInView(selected.bbox)) {
|
||||||
|
this.zoomToBbox(selected.bbox);
|
||||||
|
} else {
|
||||||
|
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||||
|
}
|
||||||
} else if (selected.bbox) {
|
} else if (selected.bbox) {
|
||||||
this.zoomToBbox(selected.bbox);
|
this.zoomToBbox(selected.bbox);
|
||||||
} else {
|
} else {
|
||||||
@@ -677,6 +684,20 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bboxFitsInView(bbox) {
|
||||||
|
if (!this.mapInstance || !bbox) return true;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const size = this.mapInstance.getSize();
|
||||||
|
const viewExtent = view.calculateExtent(size);
|
||||||
|
|
||||||
|
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||||
|
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||||
|
const bboxExtent = [...min, ...max];
|
||||||
|
|
||||||
|
return containsExtent(viewExtent, bboxExtent);
|
||||||
|
}
|
||||||
|
|
||||||
zoomToBbox(bbox) {
|
zoomToBbox(bbox) {
|
||||||
if (!this.mapInstance || !bbox) return;
|
if (!this.mapInstance || !bbox) return;
|
||||||
|
|
||||||
@@ -782,14 +803,19 @@ export default class MapComponent extends Component {
|
|||||||
// Check if mobile (width <= 768px matches CSS)
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
if (size[0] <= 768) {
|
if (size[0] <= 768) {
|
||||||
// On mobile, the bottom 50% is covered by the sheet.
|
// On mobile, the bottom 50% is covered by the sheet.
|
||||||
// We want the pin to be in the center of the TOP 50% (visible area).
|
// We want the pin to be in the center of the TOP 50% (visible area), minus the header.
|
||||||
// That means the pin should be at y = height * 0.25 (25% down from top).
|
|
||||||
// The map center is at y = height * 0.50.
|
|
||||||
// So the pin is "above" the center by 25% of the height in pixels.
|
|
||||||
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
|
||||||
|
|
||||||
const height = size[1];
|
const height = size[1];
|
||||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
const headerEl = document.querySelector('.app-header');
|
||||||
|
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||||
|
|
||||||
|
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
|
||||||
|
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
|
||||||
|
|
||||||
|
// The map center is at y = height * 0.50.
|
||||||
|
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
|
||||||
|
// To put the pin there, the map center needs to be "below" the pin by that amount.
|
||||||
|
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
|
||||||
const offsetMapUnits = offsetPixels * resolution;
|
const offsetMapUnits = offsetPixels * resolution;
|
||||||
|
|
||||||
// Shift center SOUTH (decrease Y).
|
// Shift center SOUTH (decrease Y).
|
||||||
@@ -849,6 +875,9 @@ export default class MapComponent extends Component {
|
|||||||
let targetPixelY = pixel[1];
|
let targetPixelY = pixel[1];
|
||||||
let needsPan = false;
|
let needsPan = false;
|
||||||
|
|
||||||
|
const headerEl = document.querySelector('.app-header');
|
||||||
|
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
|
||||||
|
|
||||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||||
if (size[0] <= 768) {
|
if (size[0] <= 768) {
|
||||||
const height = size[1];
|
const height = size[1];
|
||||||
@@ -856,7 +885,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// If in bottom half
|
// If in bottom half
|
||||||
if (pixel[1] > splitPoint) {
|
if (pixel[1] > splitPoint) {
|
||||||
targetPixelY = height * 0.25; // Target: Center of top half
|
targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
|
||||||
needsPan = true;
|
needsPan = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,11 +906,10 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// 3. Header Logic (Any screen size)
|
// 3. Header Logic (Any screen size)
|
||||||
// Check if the (potentially new) target Y is under the header
|
// Check if the (potentially new) target Y is under the header
|
||||||
const headerHeight = 60;
|
const minTopDistance = headerHeight + 20; // Provide some padding
|
||||||
const minTopDistance = headerHeight + 20; // 80px
|
|
||||||
|
|
||||||
if (targetPixelY < minTopDistance) {
|
if (targetPixelY < minTopDistance) {
|
||||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
targetPixelY = minTopDistance + 30; // Move it clear of header
|
||||||
needsPan = true;
|
needsPan = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1067,7 @@ export default class MapComponent extends Component {
|
|||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const center = toLonLat(view.getCenter());
|
const center = toLonLat(view.getCenter());
|
||||||
this.mapUi.updateCenter(center[1], center[0]);
|
this.mapUi.updateCenter(center[1], center[0]);
|
||||||
|
this.mapUi.updateZoom(view.getZoom());
|
||||||
|
|
||||||
// If in creation mode, update the coordinates in the service AND the URL
|
// If in creation mode, update the coordinates in the service AND the URL
|
||||||
if (this.mapUi.isCreating) {
|
if (this.mapUi.isCreating) {
|
||||||
@@ -1079,6 +1108,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);
|
||||||
|
if (this.isDestroying || this.isDestroyed) return;
|
||||||
this.nostrData.loadPlacesInBounds(bbox);
|
this.nostrData.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
@@ -1144,6 +1174,8 @@ export default class MapComponent extends Component {
|
|||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
}
|
}
|
||||||
this.mapUi.preventNextZoom = true;
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1180,6 +1212,12 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||||
|
console.debug('Clearing active search and markers on map click');
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Require Zoom >= 17 for generic map searches
|
// Require Zoom >= 17 for generic map searches
|
||||||
// This prevents accidental searches when interacting with the map at a high level
|
// This prevents accidental searches when interacting with the map at a high level
|
||||||
const currentZoom = this.mapInstance.getView().getZoom();
|
const currentZoom = this.mapInstance.getView().getZoom();
|
||||||
|
|||||||
+52
-20
@@ -1,9 +1,43 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
|
||||||
|
const ModalContent = <template>
|
||||||
|
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
{{on "click" @stopProp}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||||
|
disabled={{@disableClose}}
|
||||||
|
{{on "click" @close}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
{{yield}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
export default class Modal extends Component {
|
export default class Modal extends Component {
|
||||||
|
get isTesting() {
|
||||||
|
return config.environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
get shouldPortal() {
|
||||||
|
return !this.isTesting && !this.args.inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationElement() {
|
||||||
|
return document.getElementById('modal-portal') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
stopProp(e) {
|
stopProp(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -11,33 +45,31 @@ export default class Modal extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
|
if (this.args.disableClose) return;
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
{{#if this.shouldPortal}}
|
||||||
class="modal-overlay"
|
{{#in-element this.destinationElement}}
|
||||||
role="dialog"
|
<ModalContent
|
||||||
tabindex="-1"
|
@close={{this.close}}
|
||||||
{{on "click" this.close}}
|
@stopProp={{this.stopProp}}
|
||||||
>
|
@disableClose={{@disableClose}}
|
||||||
<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}} />
|
{{yield}}
|
||||||
</button>
|
</ModalContent>
|
||||||
|
{{/in-element}}
|
||||||
|
{{else}}
|
||||||
|
<ModalContent
|
||||||
|
@close={{this.close}}
|
||||||
|
@stopProp={{this.stopProp}}
|
||||||
|
@disableClose={{@disableClose}}
|
||||||
|
>
|
||||||
{{yield}}
|
{{yield}}
|
||||||
</div>
|
</ModalContent>
|
||||||
</div>
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,40 @@ export default class NostrConnectComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async copyConnectUri() {
|
||||||
|
const text = this.nostrAuth.connectUri;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.top = '0';
|
||||||
|
textArea.style.left = '0';
|
||||||
|
textArea.style.opacity = '0';
|
||||||
|
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (!successful) {
|
||||||
|
throw new Error('Fallback copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.toast.show('Connection link copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
alert('Failed to copy link');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="nostr-connect-modal">
|
<div class="nostr-connect-modal">
|
||||||
<h2>Connect with Nostr</h2>
|
<h2>Connect with Nostr</h2>
|
||||||
@@ -59,7 +93,7 @@ export default class NostrConnectComponent extends Component {
|
|||||||
class="btn btn-outline"
|
class="btn btn-outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
title="No Nostr extension found in your browser."
|
title="No Nostr extension found in your browser"
|
||||||
>
|
>
|
||||||
Browser Extension (Not Found)
|
Browser Extension (Not Found)
|
||||||
</button>
|
</button>
|
||||||
@@ -79,9 +113,20 @@ export default class NostrConnectComponent extends Component {
|
|||||||
{{#if this.nostrAuth.isMobile}}
|
{{#if this.nostrAuth.isMobile}}
|
||||||
<p>Waiting for you to approve the connection in your mobile signer
|
<p>Waiting for you to approve the connection in your mobile signer
|
||||||
app...</p>
|
app...</p>
|
||||||
|
<div class="mobile-connect-actions">
|
||||||
|
<a href={{this.nostrAuth.connectUri}} class="btn btn-primary">
|
||||||
|
Open Signer App
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.copyConnectUri}}
|
||||||
|
>
|
||||||
|
Copy Connection Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>Scan this QR code with a compatible Nostr signer app (like
|
<p>Scan this QR code with a Nostr signer app (like Amber):</p>
|
||||||
Amber):</p>
|
|
||||||
<div class="qr-code-container">
|
<div class="qr-code-container">
|
||||||
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { and, eq } from 'ember-truth-helpers';
|
||||||
|
import Blurhash from './blurhash';
|
||||||
|
import Icon from './icon';
|
||||||
|
import fadeInImage from '../modifiers/fade-in-image';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
|
||||||
|
export default class PhotoCarousel extends Component {
|
||||||
|
@tracked canScrollLeft = false;
|
||||||
|
@tracked canScrollRight = false;
|
||||||
|
|
||||||
|
internalEventId = null;
|
||||||
|
carouselElement = null;
|
||||||
|
|
||||||
|
get photos() {
|
||||||
|
return this.args.photos || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get showChevrons() {
|
||||||
|
// Only show chevrons if there's more than one photo
|
||||||
|
return this.photos.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cannotScrollLeft() {
|
||||||
|
return !this.canScrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cannotScrollRight() {
|
||||||
|
return !this.canScrollRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isGalleryMain() {
|
||||||
|
return this.args.variant === 'gallery-main';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isGalleryThumbnails() {
|
||||||
|
return this.args.variant === 'gallery-thumbnails';
|
||||||
|
}
|
||||||
|
|
||||||
|
get variantClass() {
|
||||||
|
return this.args.variant || 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResetKey = null;
|
||||||
|
|
||||||
|
resetScrollPosition = modifier((element, [resetKey]) => {
|
||||||
|
if (resetKey !== undefined && resetKey !== this.lastResetKey) {
|
||||||
|
this.lastResetKey = resetKey;
|
||||||
|
element.scrollLeft = 0;
|
||||||
|
setTimeout(() => this.updateScrollState(), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
isProgrammaticScroll = false;
|
||||||
|
|
||||||
|
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||||
|
if (eventId && eventId !== this.lastEventId) {
|
||||||
|
const isInitial = !this.lastEventId;
|
||||||
|
this.lastEventId = eventId;
|
||||||
|
|
||||||
|
// Prevent feedback loop if this carousel initiated the change
|
||||||
|
if (this.internalEventId === eventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.internalEventId = eventId;
|
||||||
|
this.isProgrammaticScroll = true;
|
||||||
|
|
||||||
|
const scrollAction = () => {
|
||||||
|
const targetSlide = element.querySelector(
|
||||||
|
`[data-event-id="${eventId}"]`
|
||||||
|
);
|
||||||
|
if (targetSlide) {
|
||||||
|
if (isInitial) {
|
||||||
|
const originalScrollBehavior = element.style.scrollBehavior;
|
||||||
|
element.style.scrollBehavior = 'auto';
|
||||||
|
element.scrollLeft = targetSlide.offsetLeft;
|
||||||
|
|
||||||
|
// Restore smooth scroll after the jump
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.scrollBehavior = originalScrollBehavior;
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// Use native CSS smooth scrolling for subsequent clicks
|
||||||
|
element.scrollLeft = targetSlide.offsetLeft;
|
||||||
|
// Clear programmatic scroll flag after a delay to let scroll finish
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isProgrammaticScroll = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInitial) {
|
||||||
|
// Execute immediately for the first render to prevent flash
|
||||||
|
scrollAction();
|
||||||
|
} else {
|
||||||
|
// Allow DOM to update first for subsequent clicks
|
||||||
|
setTimeout(scrollAction, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setupCarousel = modifier((element) => {
|
||||||
|
this.carouselElement = element;
|
||||||
|
|
||||||
|
// Defer the initial calculation slightly to ensure CSS and images have applied
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateScrollState();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
let resizeObserver;
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
resizeObserver = new ResizeObserver(() => this.updateScrollState());
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
let intersectionObserver;
|
||||||
|
if (
|
||||||
|
this.args.onVisiblePhotoChange &&
|
||||||
|
window.IntersectionObserver &&
|
||||||
|
config.environment !== 'test'
|
||||||
|
) {
|
||||||
|
// Set up intersection observer to track which photo is currently "most" visible
|
||||||
|
intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (this.isProgrammaticScroll) return;
|
||||||
|
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||||
|
const eventId = entry.target.dataset.eventId;
|
||||||
|
this.internalEventId = eventId;
|
||||||
|
const photo = this.photos.find((p) => p.eventId === eventId);
|
||||||
|
if (photo) {
|
||||||
|
this.args.onVisiblePhotoChange(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: element,
|
||||||
|
threshold: 0.5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const slides = element.querySelectorAll('.carousel-slide');
|
||||||
|
slides.forEach((slide) => intersectionObserver.observe(slide));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.unobserve(element);
|
||||||
|
}
|
||||||
|
if (intersectionObserver) {
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateScrollState() {
|
||||||
|
if (!this.carouselElement) return;
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement;
|
||||||
|
// tolerance of 1px for floating point rounding issues
|
||||||
|
this.canScrollLeft = scrollLeft > 1;
|
||||||
|
this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
scrollLeft() {
|
||||||
|
if (!this.carouselElement) return;
|
||||||
|
this.carouselElement.scrollBy({
|
||||||
|
left: -this.carouselElement.clientWidth,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
scrollRight() {
|
||||||
|
if (!this.carouselElement) return;
|
||||||
|
this.carouselElement.scrollBy({
|
||||||
|
left: this.carouselElement.clientWidth,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handlePhotoClick(photo) {
|
||||||
|
if (this.args.onPhotoClick) {
|
||||||
|
this.args.onPhotoClick(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.photos.length}}
|
||||||
|
<div class="photo-carousel {{this.variantClass}}">
|
||||||
|
<div
|
||||||
|
class="photo-carousel-track"
|
||||||
|
{{this.setupCarousel}}
|
||||||
|
{{this.resetScrollPosition @resetKey}}
|
||||||
|
{{this.scrollToNewPhoto @scrollToEventId}}
|
||||||
|
{{on "scroll" this.updateScrollState}}
|
||||||
|
>
|
||||||
|
{{#each this.photos as |photo|}}
|
||||||
|
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
|
||||||
|
<div
|
||||||
|
class="carousel-slide
|
||||||
|
{{if photo.isLandscape 'landscape' 'portrait'}}
|
||||||
|
{{if
|
||||||
|
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
|
||||||
|
'active'
|
||||||
|
}}"
|
||||||
|
style={{photo.style}}
|
||||||
|
data-event-id={{photo.eventId}}
|
||||||
|
{{on "click" (fn this.handlePhotoClick photo)}}
|
||||||
|
>
|
||||||
|
{{#if photo.blurhash}}
|
||||||
|
<Blurhash
|
||||||
|
@hash={{photo.blurhash}}
|
||||||
|
@width={{32}}
|
||||||
|
@height={{18}}
|
||||||
|
class="place-header-photo-blur"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isGalleryMain}}
|
||||||
|
<img
|
||||||
|
data-src={{photo.url}}
|
||||||
|
class="place-header-photo
|
||||||
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
|
alt={{photo.alt}}
|
||||||
|
{{fadeInImage photo.url}}
|
||||||
|
/>
|
||||||
|
{{else if this.isGalleryThumbnails}}
|
||||||
|
<img
|
||||||
|
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||||
|
class="place-header-photo
|
||||||
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
|
alt={{photo.alt}}
|
||||||
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
{{#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={{photo.alt}}
|
||||||
|
{{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={{photo.alt}}
|
||||||
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<div class="carousel-placeholder"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.showChevrons}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-nav-btn prev
|
||||||
|
{{if this.cannotScrollLeft 'disabled'}}"
|
||||||
|
{{on "click" this.scrollLeft}}
|
||||||
|
disabled={{this.cannotScrollLeft}}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
@name="chevron-left"
|
||||||
|
@color="currentColor"
|
||||||
|
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-nav-btn next
|
||||||
|
{{if this.cannotScrollRight 'disabled'}}"
|
||||||
|
{{on "click" this.scrollRight}}
|
||||||
|
disabled={{this.cannotScrollRight}}
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
@name="chevron-right"
|
||||||
|
@color="currentColor"
|
||||||
|
@size={{if (eq @variant "gallery-main") 24 16}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
import DropdownMenu from './dropdown-menu';
|
||||||
|
import PhotoCarousel from './photo-carousel';
|
||||||
|
import ZapPhotoModal from './zap-photo-modal';
|
||||||
|
import Icon from './icon';
|
||||||
|
import formatRelativeDate from '../helpers/format-relative-date';
|
||||||
|
|
||||||
|
const GalleryContent = <template>
|
||||||
|
<div
|
||||||
|
class="photo-gallery-overlay"
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
{{on "click" @handleBackgroundClick}}
|
||||||
|
{{@bindKeyboard @handleKeydown}}
|
||||||
|
>
|
||||||
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
|
<div
|
||||||
|
class="photo-gallery-content"
|
||||||
|
data-current-event-id={{@currentPhoto.eventId}}
|
||||||
|
>
|
||||||
|
<div class="photo-gallery-header">
|
||||||
|
<div class="actions-btn-container">
|
||||||
|
<DropdownMenu
|
||||||
|
@iconSize={{24}}
|
||||||
|
@triggerIcon={{@triggerIcon}}
|
||||||
|
@iconColor="white"
|
||||||
|
as |closeMenu|
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @copyEventId closeMenu)}}
|
||||||
|
>Copy Photo Event ID</button>
|
||||||
|
{{#if @canZapPhoto}}
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @openZap closeMenu)}}
|
||||||
|
>Zap this photo</button>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @canDeletePhoto}}
|
||||||
|
<button
|
||||||
|
class="dropdown-item text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||||
|
>Delete Photo</button>
|
||||||
|
{{/if}}
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if (or @uploaderName @photoDate)}}
|
||||||
|
<div class="photo-gallery-uploader-info">
|
||||||
|
{{#if @uploaderName}}
|
||||||
|
<span class="photo-gallery-uploader-name">{{@uploaderName}}</span>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @photoDate}}
|
||||||
|
<span class="photo-gallery-uploader-date">
|
||||||
|
{{formatRelativeDate @photoDate}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-btn btn-text"
|
||||||
|
{{on "click" @handleClose}}
|
||||||
|
aria-label="Close gallery"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{24}} @color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="main-photo-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-main"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{@currentPhoto.eventId}}
|
||||||
|
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thumbnail-strip-container">
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="gallery-thumbnails"
|
||||||
|
@photos={{@photos}}
|
||||||
|
@scrollToEventId={{@currentPhoto.eventId}}
|
||||||
|
@onPhotoClick={{@selectPhoto}}
|
||||||
|
@name={{@placeName}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if @zapModalOpen}}
|
||||||
|
<ZapPhotoModal @photo={{@currentPhoto}} @onClose={{@closeZap}} />
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default class PhotoGallery extends Component {
|
||||||
|
get isTesting() {
|
||||||
|
return config.environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationElement() {
|
||||||
|
return document.getElementById('modal-portal') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@service toast;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service nostrRelay;
|
||||||
|
@service nostrZap;
|
||||||
|
@service blossom;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||||
|
@tracked zapModalOpen = false;
|
||||||
|
|
||||||
|
get triggerIcon() {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
|
||||||
|
return 'more-vertical';
|
||||||
|
}
|
||||||
|
return 'more-horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCreator() {
|
||||||
|
return (
|
||||||
|
this.currentPhoto?.pubkey &&
|
||||||
|
this.nostrAuth.pubkey &&
|
||||||
|
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canDeletePhoto() {
|
||||||
|
return (
|
||||||
|
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canZapPhoto() {
|
||||||
|
return (
|
||||||
|
!this.isCreator &&
|
||||||
|
this.nostrAuth.isConnected &&
|
||||||
|
!!this.nostrZap.getLightningAddress(this.currentPhoto?.pubkey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploaderName() {
|
||||||
|
const pubkey = this.currentPhoto?.pubkey;
|
||||||
|
if (!pubkey) return null;
|
||||||
|
const profile = this.nostrData.getProfile?.(pubkey);
|
||||||
|
if (!profile) return null;
|
||||||
|
return profile.displayName || profile.display_name || profile.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get photoDate() {
|
||||||
|
const ts = this.currentPhoto?.publishedAt || this.currentPhoto?.createdAt;
|
||||||
|
return ts || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard = modifier((element, [handler]) => {
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleClose() {
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleBackgroundClick(e) {
|
||||||
|
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
|
||||||
|
if (
|
||||||
|
e.target.closest('.thumbnail-strip-container') ||
|
||||||
|
e.target.closest('.carousel-nav-btn') ||
|
||||||
|
e.target.closest('.close-btn') ||
|
||||||
|
e.target.closest('.photo-gallery-header') ||
|
||||||
|
e.target.closest('.modal-overlay')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectPhoto(photo) {
|
||||||
|
this.currentPhoto = photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleVisiblePhotoChange(photo) {
|
||||||
|
if (this.currentPhoto !== photo) {
|
||||||
|
this.currentPhoto = photo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.args.photos || this.args.photos.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex - 1];
|
||||||
|
} else if (
|
||||||
|
e.key === 'ArrowRight' &&
|
||||||
|
currentIndex < this.args.photos.length - 1
|
||||||
|
) {
|
||||||
|
this.currentPhoto = this.args.photos[currentIndex + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async copyEventId(closeMenu) {
|
||||||
|
if (this.currentPhoto?.eventId) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||||
|
this.toast.show('Event ID copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy event ID:', err);
|
||||||
|
this.toast.show('Failed to copy event ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openZap(closeMenu, e) {
|
||||||
|
e?.stopPropagation();
|
||||||
|
this.zapModalOpen = true;
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeZap() {
|
||||||
|
this.zapModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async (closeMenu) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventId = this.currentPhoto.eventId;
|
||||||
|
|
||||||
|
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||||
|
const tags = [['e', eventId]];
|
||||||
|
|
||||||
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await EventFactory.fromKind(5)
|
||||||
|
.content('Deleted photo')
|
||||||
|
.modifyPublicTags(() => tags)
|
||||||
|
.as(this.nostrAuth.signer)
|
||||||
|
.sign();
|
||||||
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
|
|
||||||
|
// Remove from local store by adding the kind 5 to it
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
// Now that the event is published, try to delete from Blossom
|
||||||
|
const hashRegex = /[0-9a-f]{64}/i;
|
||||||
|
|
||||||
|
if (this.currentPhoto.url) {
|
||||||
|
const match = this.currentPhoto.url.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete main image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPhoto.thumbUrl) {
|
||||||
|
const match = this.currentPhoto.thumbUrl.match(hashRegex);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
await this.blossom.delete(match[0]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete thumb image from blossom:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toast.show('Photo deleted successfully');
|
||||||
|
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
this.handleClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete photo:', e);
|
||||||
|
this.toast.show('Failed to delete photo: ' + e.message);
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.isTesting}}
|
||||||
|
<GalleryContent
|
||||||
|
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||||
|
@bindKeyboard={{this.bindKeyboard}}
|
||||||
|
@handleKeydown={{this.handleKeydown}}
|
||||||
|
@copyEventId={{this.copyEventId}}
|
||||||
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
|
@handleClose={{this.handleClose}}
|
||||||
|
@photos={{@photos}}
|
||||||
|
@currentPhoto={{this.currentPhoto}}
|
||||||
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
|
@placeName={{@placeName}}
|
||||||
|
@selectPhoto={{this.selectPhoto}}
|
||||||
|
@uploaderName={{this.uploaderName}}
|
||||||
|
@photoDate={{this.photoDate}}
|
||||||
|
@triggerIcon={{this.triggerIcon}}
|
||||||
|
@openZap={{this.openZap}}
|
||||||
|
@closeZap={{this.closeZap}}
|
||||||
|
@zapModalOpen={{this.zapModalOpen}}
|
||||||
|
@canZapPhoto={{this.canZapPhoto}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
{{#in-element this.destinationElement}}
|
||||||
|
<GalleryContent
|
||||||
|
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||||
|
@bindKeyboard={{this.bindKeyboard}}
|
||||||
|
@handleKeydown={{this.handleKeydown}}
|
||||||
|
@copyEventId={{this.copyEventId}}
|
||||||
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
|
@deletePhotoTask={{this.deletePhotoTask}}
|
||||||
|
@handleClose={{this.handleClose}}
|
||||||
|
@photos={{@photos}}
|
||||||
|
@currentPhoto={{this.currentPhoto}}
|
||||||
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
|
@placeName={{@placeName}}
|
||||||
|
@selectPhoto={{this.selectPhoto}}
|
||||||
|
@uploaderName={{this.uploaderName}}
|
||||||
|
@photoDate={{this.photoDate}}
|
||||||
|
@triggerIcon={{this.triggerIcon}}
|
||||||
|
@openZap={{this.openZap}}
|
||||||
|
@closeZap={{this.closeZap}}
|
||||||
|
@zapModalOpen={{this.zapModalOpen}}
|
||||||
|
@canZapPhoto={{this.canZapPhoto}}
|
||||||
|
/>
|
||||||
|
{{/in-element}}
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager';
|
|||||||
import PlacePhotoUpload from './place-photo-upload';
|
import PlacePhotoUpload from './place-photo-upload';
|
||||||
import NostrConnect from './nostr-connect';
|
import NostrConnect from './nostr-connect';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
import PlacePhotosCarousel from './place-photos-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
|
import PhotoGallery from './photo-gallery';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -22,12 +23,22 @@ export default class PlaceDetails extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service mapUi;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
|
@tracked isPhotoUploadActive = false;
|
||||||
|
@tracked isConnectingNostr = false;
|
||||||
|
@tracked isGalleryOpen = false;
|
||||||
|
@tracked selectedGalleryPhoto = null;
|
||||||
@tracked isPhotoUploadModalOpen = false;
|
@tracked isPhotoUploadModalOpen = false;
|
||||||
@tracked isNostrConnectModalOpen = false;
|
@tracked isNostrConnectModalOpen = false;
|
||||||
@tracked newlyUploadedPhotoId = null;
|
@tracked newlyUploadedPhotoId = null;
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleUploadStateChange(isActive) {
|
||||||
|
this.isPhotoUploadActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
openPhotoUploadModal(e) {
|
openPhotoUploadModal(e) {
|
||||||
if (e) {
|
if (e) {
|
||||||
@@ -42,6 +53,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
closePhotoUploadModal(eventId) {
|
closePhotoUploadModal(eventId) {
|
||||||
|
if (this.isPhotoUploadActive) return;
|
||||||
this.isPhotoUploadModalOpen = false;
|
this.isPhotoUploadModalOpen = false;
|
||||||
if (typeof eventId === 'string') {
|
if (typeof eventId === 'string') {
|
||||||
this.newlyUploadedPhotoId = eventId;
|
this.newlyUploadedPhotoId = eventId;
|
||||||
@@ -220,7 +232,7 @@ export default class PlaceDetails extends Component {
|
|||||||
return htmlSafe(
|
return htmlSafe(
|
||||||
parts
|
parts
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const safeTel = p.replace(/[\s-]+/g, '');
|
const safeTel = p.replace(/[\s()+.-]/g, '');
|
||||||
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||||
})
|
})
|
||||||
.join('<br>')
|
.join('<br>')
|
||||||
@@ -334,9 +346,21 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
get osmUrl() {
|
get osmUrl() {
|
||||||
const id = this.place.osmId;
|
const id = this.place.osmId;
|
||||||
if (!id) return null;
|
if (id) {
|
||||||
const type = this.place.osmType || 'node';
|
const type = this.place.osmType || 'node';
|
||||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = this.place.lat;
|
||||||
|
const lon = this.place.lon;
|
||||||
|
if (!lat || !lon) return null;
|
||||||
|
|
||||||
|
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
|
||||||
|
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
|
||||||
|
const zoom = this.mapUi.currentZoom ?? 17;
|
||||||
|
const roundedZoom = Math.round(zoom);
|
||||||
|
|
||||||
|
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get gmapsUrl() {
|
get gmapsUrl() {
|
||||||
@@ -355,6 +379,18 @@ export default class PlaceDetails extends Component {
|
|||||||
return !!this.place.description;
|
return !!this.place.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openGallery(photo) {
|
||||||
|
this.selectedGalleryPhoto = photo;
|
||||||
|
this.isGalleryOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeGallery() {
|
||||||
|
this.isGalleryOpen = false;
|
||||||
|
this.selectedGalleryPhoto = null;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
{{#if this.isEditing}}
|
{{#if this.isEditing}}
|
||||||
@@ -364,11 +400,13 @@ export default class PlaceDetails extends Component {
|
|||||||
@onCancel={{this.cancelEditing}}
|
@onCancel={{this.cancelEditing}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
<PlacePhotosCarousel
|
<PhotoCarousel
|
||||||
|
@variant="inline"
|
||||||
@photos={{this.photos}}
|
@photos={{this.photos}}
|
||||||
@name={{this.name}}
|
@name={{this.name}}
|
||||||
@resetKey={{this.place.osmId}}
|
@resetKey={{this.place.osmId}}
|
||||||
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||||
|
@onPhotoClick={{this.openGallery}}
|
||||||
/>
|
/>
|
||||||
<h3>{{this.name}}</h3>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
@@ -566,10 +604,10 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.osmUrl}}
|
{{#if this.place.osmId}}
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="camera" />
|
<Icon @name="feather-camera" />
|
||||||
<span>
|
<span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -585,10 +623,14 @@ export default class PlaceDetails extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.isPhotoUploadModalOpen}}
|
{{#if this.isPhotoUploadModalOpen}}
|
||||||
<Modal @onClose={{this.closePhotoUploadModal}}>
|
<Modal
|
||||||
|
@onClose={{this.closePhotoUploadModal}}
|
||||||
|
@disableClose={{this.isPhotoUploadActive}}
|
||||||
|
>
|
||||||
<PlacePhotoUpload
|
<PlacePhotoUpload
|
||||||
@place={{this.saveablePlace}}
|
@place={{this.saveablePlace}}
|
||||||
@onClose={{this.closePhotoUploadModal}}
|
@onClose={{this.closePhotoUploadModal}}
|
||||||
|
@onUploadStateChange={{this.handleUploadStateChange}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -598,5 +640,14 @@ export default class PlaceDetails extends Component {
|
|||||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
</Modal>
|
</Modal>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isGalleryOpen}}
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedGalleryPhoto}}
|
||||||
|
@placeName={{this.name}}
|
||||||
|
@onClose={{this.closeGallery}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default class PlaceListsManager extends Component {
|
|||||||
checked={{this.isSaved}}
|
checked={{this.isSaved}}
|
||||||
{{on "change" this.toggleSaved}}
|
{{on "change" this.toggleSaved}}
|
||||||
/>
|
/>
|
||||||
<span class="list-color"></span>
|
<span class="list-color-dot"></span>
|
||||||
<span class="list-name">Saved places</span>
|
<span class="list-name">Saved places</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +122,7 @@ export default class PlaceListsManager extends Component {
|
|||||||
/>
|
/>
|
||||||
{{! template-lint-disable no-inline-styles }}
|
{{! template-lint-disable no-inline-styles }}
|
||||||
<span
|
<span
|
||||||
class="list-color"
|
class="list-color-dot"
|
||||||
style={{this.styleFor list.color}}
|
style={{this.styleFor list.color}}
|
||||||
></span>
|
></span>
|
||||||
<span class="list-name">{{list.title}}</span>
|
<span class="list-name">{{list.title}}</span>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { isMobile } from '../utils/device';
|
|||||||
import Blurhash from './blurhash';
|
import Blurhash from './blurhash';
|
||||||
|
|
||||||
const MAX_IMAGE_DIMENSION = 1920;
|
const MAX_IMAGE_DIMENSION = 1920;
|
||||||
const IMAGE_QUALITY = 0.94;
|
const IMAGE_QUALITY = 0.9;
|
||||||
const MAX_THUMBNAIL_DIMENSION = 350;
|
const MAX_THUMBNAIL_DIMENSION = 350;
|
||||||
const THUMBNAIL_QUALITY = 0.9;
|
const THUMBNAIL_QUALITY = 0.9;
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export default class PlacePhotoUploadItem extends Component {
|
|||||||
@tracked thumbnailUrl = '';
|
@tracked thumbnailUrl = '';
|
||||||
@tracked blurhash = '';
|
@tracked blurhash = '';
|
||||||
@tracked error = '';
|
@tracked error = '';
|
||||||
|
@tracked statusText = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@@ -47,6 +48,7 @@ export default class PlacePhotoUploadItem extends Component {
|
|||||||
|
|
||||||
uploadTask = task(async (file) => {
|
uploadTask = task(async (file) => {
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.statusText = 'Processing';
|
||||||
try {
|
try {
|
||||||
// 1. Process main image and generate blurhash in worker
|
// 1. Process main image and generate blurhash in worker
|
||||||
const mainData = await this.imageProcessor.process(
|
const mainData = await this.imageProcessor.process(
|
||||||
@@ -71,18 +73,34 @@ export default class PlacePhotoUploadItem extends Component {
|
|||||||
let mainResult, thumbResult;
|
let mainResult, thumbResult;
|
||||||
const isMobileDevice = isMobile();
|
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) {
|
if (isMobileDevice) {
|
||||||
// Mobile: sequential uploads to preserve bandwidth and memory
|
// Mobile: sequential uploads to preserve bandwidth and memory
|
||||||
mainResult = await this.blossom.upload(mainData.blob, {
|
mainResult = await this.blossom.upload(mainData.blob, {
|
||||||
sequential: true,
|
sequential: true,
|
||||||
|
onProgress: mainProgress,
|
||||||
});
|
});
|
||||||
thumbResult = await this.blossom.upload(thumbData.blob, {
|
thumbResult = await this.blossom.upload(thumbData.blob, {
|
||||||
sequential: true,
|
sequential: true,
|
||||||
|
onProgress: thumbProgress,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Desktop: concurrent uploads
|
// Desktop: concurrent uploads
|
||||||
const mainUploadPromise = this.blossom.upload(mainData.blob);
|
const mainUploadPromise = this.blossom.upload(mainData.blob, {
|
||||||
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
|
onProgress: mainProgress,
|
||||||
|
});
|
||||||
|
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
|
||||||
|
onProgress: thumbProgress,
|
||||||
|
});
|
||||||
|
|
||||||
[mainResult, thumbResult] = await Promise.all([
|
[mainResult, thumbResult] = await Promise.all([
|
||||||
mainUploadPromise,
|
mainUploadPromise,
|
||||||
@@ -127,6 +145,9 @@ export default class PlacePhotoUploadItem extends Component {
|
|||||||
@color="white"
|
@color="white"
|
||||||
class="spin-animation"
|
class="spin-animation"
|
||||||
/>
|
/>
|
||||||
|
{{#if this.statusText}}
|
||||||
|
<span class="upload-status-text">{{this.statusText}}</span>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { task } from 'ember-concurrency';
|
|||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
import PlacePhotoUploadItem from './place-photo-upload-item';
|
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
|
import { getSuggestedPhotoTags } from '../utils/photo-tag-suggestions';
|
||||||
|
import capitalize from '../helpers/capitalize';
|
||||||
|
import includes from '../helpers/includes';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
import { or, not } from 'ember-truth-helpers';
|
import { or, not } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class PlacePhotoUpload extends Component {
|
export default class PlacePhotoUpload extends Component {
|
||||||
@@ -19,10 +23,11 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
|
|
||||||
@tracked file = null;
|
@tracked file = null;
|
||||||
@tracked uploadedPhoto = null;
|
@tracked uploadedPhoto = null;
|
||||||
@tracked status = '';
|
|
||||||
@tracked error = '';
|
@tracked error = '';
|
||||||
@tracked isPublishing = false;
|
@tracked isPublishing = false;
|
||||||
@tracked isDragging = false;
|
@tracked isDragging = false;
|
||||||
|
@tracked selectedTags = [];
|
||||||
|
@tracked altText = '';
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -38,6 +43,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get suggestedTags() {
|
||||||
|
return getSuggestedPhotoTags(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleFileSelect(event) {
|
handleFileSelect(event) {
|
||||||
this.addFile(event.target.files[0]);
|
this.addFile(event.target.files[0]);
|
||||||
@@ -77,6 +86,9 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
this.file = file;
|
this.file = file;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -91,6 +103,26 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
this.selectedTags = [];
|
||||||
|
this.altText = '';
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleTag(tag) {
|
||||||
|
if (this.selectedTags.includes(tag)) {
|
||||||
|
this.selectedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTags = [tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateAltText(event) {
|
||||||
|
this.altText = event.target.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
deletePhotoTask = task(async (photoData) => {
|
deletePhotoTask = task(async (photoData) => {
|
||||||
@@ -126,15 +158,16 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.status = 'Publishing event...';
|
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.isPublishing = true;
|
this.isPublishing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
|
|
||||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
for (const tag of this.selectedTags) {
|
||||||
|
tags.push(['t', tag]);
|
||||||
|
}
|
||||||
|
|
||||||
if (lat && lon) {
|
if (lat && lon) {
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
@@ -151,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
imeta.push(`dim ${photo.dim}`);
|
imeta.push(`dim ${photo.dim}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
imeta.push('alt A photo of a place');
|
const alt = this.altText.trim();
|
||||||
|
if (alt) {
|
||||||
|
imeta.push(`alt ${alt}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||||
for (const fallbackUrl of photo.fallbackUrls) {
|
for (const fallbackUrl of photo.fallbackUrls) {
|
||||||
@@ -170,33 +206,30 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
tags.push(imeta);
|
tags.push(imeta);
|
||||||
|
|
||||||
// NIP-XX draft Place Photo event
|
// NIP-XX draft Place Photo event
|
||||||
const template = {
|
const event = await EventFactory.fromKind(360)
|
||||||
kind: 360,
|
.content('')
|
||||||
content: '',
|
.modifyPublicTags(() => tags)
|
||||||
tags,
|
.as(this.nostrAuth.signer)
|
||||||
};
|
.sign();
|
||||||
|
|
||||||
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);
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
this.nostrData.store.add(event);
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
this.toast.show('Photo published successfully');
|
this.toast.show('Photo published successfully');
|
||||||
this.status = '';
|
|
||||||
|
|
||||||
// Clear out the file so user can upload more or be done
|
// Clear out the file so user can upload more or be done
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
this.altText = '';
|
||||||
|
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
this.args.onClose(event.id);
|
this.args.onClose(event.id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = 'Failed to publish: ' + e.message;
|
this.error = 'Failed to publish: ' + e.message;
|
||||||
this.status = '';
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isPublishing = false;
|
this.isPublishing = false;
|
||||||
}
|
}
|
||||||
@@ -212,18 +245,43 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.status}}
|
|
||||||
<div class="alert alert-info">
|
|
||||||
{{this.status}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if this.file}}
|
{{#if this.file}}
|
||||||
<div class="photo-grid">
|
<PlacePhotoUploadItem
|
||||||
<PlacePhotoUploadItem
|
@file={{this.file}}
|
||||||
@file={{this.file}}
|
@onSuccess={{this.handleUploadSuccess}}
|
||||||
@onSuccess={{this.handleUploadSuccess}}
|
@onRemove={{this.removeFile}}
|
||||||
@onRemove={{this.removeFile}}
|
/>
|
||||||
|
|
||||||
|
{{#if this.suggestedTags.length}}
|
||||||
|
<div class="photo-tag-suggestions">
|
||||||
|
<p class="photo-tag-suggestions-title">
|
||||||
|
Choose a tag/category (optional):
|
||||||
|
</p>
|
||||||
|
<div class="photo-tag-suggestions-list">
|
||||||
|
{{#each this.suggestedTags as |tag|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="photo-tag-chip
|
||||||
|
{{if (includes this.selectedTags tag) 'is-selected'}}"
|
||||||
|
{{on "click" (fn this.toggleTag tag)}}
|
||||||
|
>
|
||||||
|
{{capitalize tag}}
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="photo-alt-input">Description (optional):</label>
|
||||||
|
<input
|
||||||
|
id="photo-alt-input"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Describe this photo"
|
||||||
|
value={{this.altText}}
|
||||||
|
disabled={{this.isPublishing}}
|
||||||
|
{{on "input" this.updateAltText}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,13 @@ import { on } from '@ember/modifier';
|
|||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import or from 'ember-truth-helpers/helpers/or';
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
import and from 'ember-truth-helpers/helpers/and';
|
||||||
|
import not from 'ember-truth-helpers/helpers/not';
|
||||||
import PlaceDetails from './place-details';
|
import PlaceDetails from './place-details';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import restoreScroll from '../modifiers/restore-scroll';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -160,7 +163,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
<div
|
||||||
|
class="sidebar-header
|
||||||
|
{{if this.hasHeaderPhoto 'no-border'}}
|
||||||
|
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
|
||||||
|
>
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -168,11 +175,32 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{on "click" this.clearSelection}}
|
{{on "click" this.clearSelection}}
|
||||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if this.isNearbySearch}}
|
{{#if @onBack}}
|
||||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon
|
||||||
Nearby</h2>
|
@name="arrow-left"
|
||||||
|
@size={{20}}
|
||||||
|
@color="#333"
|
||||||
|
/></button>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @onBack}}
|
||||||
|
<h2 class="sidebar-header-text-centered">
|
||||||
|
<span class="sidebar-header-icon-wrapper">
|
||||||
|
<Icon
|
||||||
|
@name="bookmark"
|
||||||
|
@size={{20}}
|
||||||
|
@color={{or @color "#898989"}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{{@title}}
|
||||||
|
</h2>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
{{#if this.isNearbySearch}}
|
||||||
|
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||||
|
Nearby</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2><Icon @name="search" @size={{20}} @color="#333" />
|
||||||
|
Results</h2>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||||
@@ -182,7 +210,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
/></button>
|
/></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<PlaceDetails
|
<PlaceDetails
|
||||||
@place={{@selectedPlace}}
|
@place={{@selectedPlace}}
|
||||||
@@ -190,54 +218,58 @@ export default class PlacesSidebar extends Component {
|
|||||||
@onSave={{this.updateBookmark}}
|
@onSave={{this.updateBookmark}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if @places}}
|
{{#if @isLoading}}
|
||||||
<ul class="places-list">
|
<div class="sidebar-loading">
|
||||||
{{#each @places as |place|}}
|
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||||
<li>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="place-item"
|
|
||||||
{{on "click" (fn this.selectPlace place)}}
|
|
||||||
>
|
|
||||||
<div class="place-name">{{or
|
|
||||||
place.title
|
|
||||||
place.osmTags.name
|
|
||||||
place.osmTags.name:en
|
|
||||||
"Unnamed Place"
|
|
||||||
}}</div>
|
|
||||||
<div class="place-type">
|
|
||||||
{{#if (eq place.source "osm")}}
|
|
||||||
{{humanizeOsmTag place.type}}
|
|
||||||
{{else if (eq place.source "photon")}}
|
|
||||||
{{place.description}}
|
|
||||||
{{else}}
|
|
||||||
{{#if place.osmTags}}
|
|
||||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
|
||||||
{{else if place.description}}
|
|
||||||
{{place.description}}
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if this.isNearbySearch}}
|
{{#if @places}}
|
||||||
<p class="empty-state">No places found nearby.</p>
|
<ul class="places-list">
|
||||||
|
{{#each @places as |place|}}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="place-item"
|
||||||
|
{{on "click" (fn this.selectPlace place)}}
|
||||||
|
>
|
||||||
|
<div class="place-name">{{or
|
||||||
|
place.title
|
||||||
|
place.osmTags.name
|
||||||
|
place.osmTags.name:en
|
||||||
|
"Unnamed Place"
|
||||||
|
}}</div>
|
||||||
|
<div class="place-type">
|
||||||
|
{{#if (eq place.source "osm")}}
|
||||||
|
{{humanizeOsmTag place.type}}
|
||||||
|
{{else if (eq place.source "photon")}}
|
||||||
|
{{place.description}}
|
||||||
|
{{else if (getPlaceType place.osmTags)}}
|
||||||
|
{{getPlaceType place.osmTags}}
|
||||||
|
{{else}}
|
||||||
|
Saved place
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty-state">No results found.</p>
|
{{#if this.isNearbySearch}}
|
||||||
|
<p class="empty-state">No places found nearby.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No results found.</p>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline create-place"
|
class="btn btn-outline create-place"
|
||||||
{{on "click" this.createNewPlace}}
|
{{on "click" this.createNewPlace}}
|
||||||
>
|
>
|
||||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||||
Create new place
|
Create new place
|
||||||
</button>
|
</button>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
|
|||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
this.searchTask.perform(value);
|
this.searchTask.perform(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatSavedPlace(place) {
|
||||||
|
const listNames = (place._listIds || [])
|
||||||
|
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const description = listNames
|
||||||
|
? `Saved place (${listNames})`
|
||||||
|
: 'Saved place';
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'saved',
|
||||||
|
id: place.id,
|
||||||
|
title: place.title,
|
||||||
|
icon: 'bookmark',
|
||||||
|
description,
|
||||||
|
osmId: place.osmId,
|
||||||
|
osmType: place.osmType,
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async (term) => {
|
searchTask = task({ restartable: true }, async (term) => {
|
||||||
await timeout(300);
|
await timeout(300);
|
||||||
|
|
||||||
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
icon: 'search',
|
icon: 'search',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Filter saved places (minimum 3 characters)
|
||||||
|
let savedMatches = [];
|
||||||
|
if (q.length >= 3) {
|
||||||
|
savedMatches = this.storage.savedPlaces
|
||||||
|
.filter((p) => p.title && p.title.toLowerCase().includes(q))
|
||||||
|
.map((p) => this.formatSavedPlace(p));
|
||||||
|
}
|
||||||
|
|
||||||
const results = await this.photon.search(query, lat, lon);
|
const results = await this.photon.search(query, lat, lon);
|
||||||
this.results = [...categoryMatches, ...results];
|
|
||||||
|
// Deduplicate Photon results that are already in saved matches
|
||||||
|
const savedOsmIds = new Set(
|
||||||
|
savedMatches.map((s) => s.osmId).filter(Boolean)
|
||||||
|
);
|
||||||
|
const filteredPhotonResults = results.filter(
|
||||||
|
(r) => !savedOsmIds.has(r.osmId)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.results = [
|
||||||
|
...categoryMatches,
|
||||||
|
...savedMatches,
|
||||||
|
...filteredPhotonResults,
|
||||||
|
];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
|
|||||||
}
|
}
|
||||||
this.results = []; // Hide popover
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it's a custom saved place without an OSM ID, go to place details via internal ID
|
||||||
if (place.osmId) {
|
if (place.source === 'saved' && place.id && !place.osmId) {
|
||||||
|
this.router.transitionTo('place', place.id);
|
||||||
|
}
|
||||||
|
// If it has an OSM ID, go to place details via OSM ID
|
||||||
|
else if (place.osmId) {
|
||||||
// Format: osm:node:123
|
// Format: osm:node:123
|
||||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { eq } from 'ember-truth-helpers';
|
||||||
|
import qrCode from '../modifiers/qr-code';
|
||||||
|
import Icon from './icon';
|
||||||
|
import Modal from './modal';
|
||||||
|
|
||||||
|
const SLIDER_MIN_SATS = 10;
|
||||||
|
const SLIDER_MAX_SATS = 100_000;
|
||||||
|
const SLIDER_STEPS = 200;
|
||||||
|
|
||||||
|
function sliderToSats(position) {
|
||||||
|
const t = position / SLIDER_STEPS;
|
||||||
|
return Math.round(
|
||||||
|
Math.exp(
|
||||||
|
Math.log(SLIDER_MIN_SATS) +
|
||||||
|
(Math.log(SLIDER_MAX_SATS) - Math.log(SLIDER_MIN_SATS)) * t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function satsToSlider(sats) {
|
||||||
|
return Math.round(
|
||||||
|
((Math.log(sats) - Math.log(SLIDER_MIN_SATS)) /
|
||||||
|
(Math.log(SLIDER_MAX_SATS) - Math.log(SLIDER_MIN_SATS))) *
|
||||||
|
SLIDER_STEPS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SLIDER_POSITION = satsToSlider(100);
|
||||||
|
|
||||||
|
export default class ZapPhotoModal extends Component {
|
||||||
|
@service nostrZap;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
@tracked step = 'select-amount';
|
||||||
|
@tracked sliderPosition = DEFAULT_SLIDER_POSITION;
|
||||||
|
@tracked message = '';
|
||||||
|
@tracked error = null;
|
||||||
|
@tracked invoice = null;
|
||||||
|
@tracked lightningAddress = null;
|
||||||
|
@tracked lnurlEndpoint = null;
|
||||||
|
@tracked lnurlLoading = false;
|
||||||
|
@tracked lnurlError = null;
|
||||||
|
@tracked paymentNotDetected = false;
|
||||||
|
|
||||||
|
sliderSteps = SLIDER_STEPS;
|
||||||
|
sliderTicks = ['10', '100', '1k', '10k', '100k'];
|
||||||
|
|
||||||
|
_receiptSub = null;
|
||||||
|
_receiptTimeout = null;
|
||||||
|
_zapRequest = null;
|
||||||
|
|
||||||
|
RECEIPT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this._loadLightningInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
get photo() {
|
||||||
|
return this.args.photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasWebLN() {
|
||||||
|
return this.nostrZap.hasWebLN();
|
||||||
|
}
|
||||||
|
|
||||||
|
get effectiveAmount() {
|
||||||
|
return sliderToSats(this.sliderPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
get amountMsats() {
|
||||||
|
return this.effectiveAmount * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formattedAmount() {
|
||||||
|
return this.effectiveAmount.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get cannotZap() {
|
||||||
|
return !this.canZap;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canZap() {
|
||||||
|
if (!this.lnurlEndpoint) return false;
|
||||||
|
if (this.lnurlError) return false;
|
||||||
|
if (this.effectiveAmount <= 0) return false;
|
||||||
|
return (
|
||||||
|
this.amountMsats >= this.lnurlEndpoint.minSendable &&
|
||||||
|
this.amountMsats <= this.lnurlEndpoint.maxSendable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get minSats() {
|
||||||
|
return this.lnurlEndpoint
|
||||||
|
? Math.ceil(this.lnurlEndpoint.minSendable / 1000)
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxSats() {
|
||||||
|
return this.lnurlEndpoint
|
||||||
|
? Math.floor(this.lnurlEndpoint.maxSendable / 1000)
|
||||||
|
: SLIDER_MAX_SATS;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lightningUri() {
|
||||||
|
return this.invoice ? `lightning:${this.invoice}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get parsedInvoice() {
|
||||||
|
if (!this.invoice) return null;
|
||||||
|
try {
|
||||||
|
return this.nostrZap.parseInvoice(this.invoice);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async _loadLightningInfo() {
|
||||||
|
const pubkey = this.photo?.pubkey;
|
||||||
|
if (!pubkey) return;
|
||||||
|
|
||||||
|
// Pre-warm the recipient relay cache so the zap request
|
||||||
|
// uses the recipient's NIP-65 inbox relays (where they'll
|
||||||
|
// see the zap receipt) instead of the sender's relays
|
||||||
|
this.nostrZap.loadRecipientRelays(pubkey).catch(() => {});
|
||||||
|
|
||||||
|
const address = this.nostrZap.getLightningAddress(pubkey);
|
||||||
|
if (!address) {
|
||||||
|
this.lnurlError =
|
||||||
|
'This user has no lightning address set in their profile';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lightningAddress = address;
|
||||||
|
this.lnurlLoading = true;
|
||||||
|
this.lnurlError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.lnurlEndpoint = await this.nostrZap.resolveLnurl(address);
|
||||||
|
} catch (err) {
|
||||||
|
this.lnurlError =
|
||||||
|
err instanceof Error ? err.message : 'Failed to fetch lightning info';
|
||||||
|
} finally {
|
||||||
|
this.lnurlLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@action
|
||||||
|
updateSlider(e) {
|
||||||
|
this.sliderPosition = Number(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateMessage(e) {
|
||||||
|
this.message = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleClose() {
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
resetAndClose() {
|
||||||
|
this._cleanupReceiptSub();
|
||||||
|
this.step = 'select-amount';
|
||||||
|
this.error = null;
|
||||||
|
this.invoice = null;
|
||||||
|
this.paymentNotDetected = false;
|
||||||
|
this.handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
retry() {
|
||||||
|
this.step = 'select-amount';
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
showInvoice() {
|
||||||
|
this.step = 'awaiting-payment';
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
performZap() {
|
||||||
|
this.zapTask.perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
performWebLNPay() {
|
||||||
|
this.webLNPayTask.perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
copyInvoice() {
|
||||||
|
if (!this.invoice) return;
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(this.invoice);
|
||||||
|
this.toast.show('Invoice copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startReceiptSubscription(zapRequest) {
|
||||||
|
this._cleanupReceiptSub();
|
||||||
|
this._zapRequest = zapRequest;
|
||||||
|
this.paymentNotDetected = false;
|
||||||
|
|
||||||
|
this._receiptSub = this.nostrZap.subscribeForZapReceipt(
|
||||||
|
zapRequest,
|
||||||
|
this.photo,
|
||||||
|
() => {
|
||||||
|
this.step = 'success';
|
||||||
|
this._cleanupReceiptSub();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this._receiptTimeout = setTimeout(() => {
|
||||||
|
this.paymentNotDetected = true;
|
||||||
|
}, this.RECEIPT_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupReceiptSub() {
|
||||||
|
if (this._receiptSub) {
|
||||||
|
this._receiptSub.unsubscribe();
|
||||||
|
this._receiptSub = null;
|
||||||
|
}
|
||||||
|
if (this._receiptTimeout) {
|
||||||
|
clearTimeout(this._receiptTimeout);
|
||||||
|
this._receiptTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zapTask = task(async () => {
|
||||||
|
if (!this.canZap) return;
|
||||||
|
|
||||||
|
this.step = 'fetching-invoice';
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invoice, zapRequest } = await this.nostrZap.zap(
|
||||||
|
this.photo,
|
||||||
|
this.amountMsats,
|
||||||
|
this.message
|
||||||
|
);
|
||||||
|
this.invoice = invoice;
|
||||||
|
this.step = 'awaiting-payment';
|
||||||
|
this._startReceiptSubscription(zapRequest);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Zap failed:', err);
|
||||||
|
this.error = err instanceof Error ? err.message : 'Failed to create zap';
|
||||||
|
this.step = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
webLNPayTask = task(async () => {
|
||||||
|
if (!this.invoice) return;
|
||||||
|
|
||||||
|
this.step = 'paying-with-wallet';
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.nostrZap.payWithWebln(this.invoice);
|
||||||
|
this._cleanupReceiptSub();
|
||||||
|
this.step = 'success';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebLN pay failed:', err);
|
||||||
|
this.error =
|
||||||
|
err instanceof Error ? err.message : 'Failed to pay with wallet';
|
||||||
|
this.step = 'wallet-pay-error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
this._cleanupReceiptSub();
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal @inline={{true}} @onClose={{this.resetAndClose}}>
|
||||||
|
<div class="zap-photo-modal">
|
||||||
|
<h2>Zap Photo</h2>
|
||||||
|
|
||||||
|
{{! Lightning address status }}
|
||||||
|
<section class="status">
|
||||||
|
{{#if this.lnurlLoading}}
|
||||||
|
<p class="meta-info">Loading lightning address...</p>
|
||||||
|
{{else if this.lightningAddress}}
|
||||||
|
<div class="lightning-address">
|
||||||
|
<span class="lightning-badge">Lightning</span>
|
||||||
|
<span
|
||||||
|
class="lightning-address-text"
|
||||||
|
>{{this.lightningAddress}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.lnurlError}}
|
||||||
|
<div class="alert alert-error">{{this.lnurlError}}</div>
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{! Step: Select Amount }}
|
||||||
|
{{#if (eq this.step "select-amount")}}
|
||||||
|
<section class="slider">
|
||||||
|
<div class="amount-display">
|
||||||
|
<span
|
||||||
|
class="amount-value zap-amount"
|
||||||
|
>{{this.formattedAmount}}</span>
|
||||||
|
<span class="amount-unit">sats</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="zap-slider"
|
||||||
|
min="0"
|
||||||
|
max={{this.sliderSteps}}
|
||||||
|
value={{this.sliderPosition}}
|
||||||
|
aria-label="Zap amount in sats"
|
||||||
|
{{on "input" this.updateSlider}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="slider-ticks">
|
||||||
|
{{#each this.sliderTicks as |label|}}
|
||||||
|
<span>{{label}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Message (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Say something nice..."
|
||||||
|
aria-label="Zap message"
|
||||||
|
value={{this.message}}
|
||||||
|
{{on "input" this.updateMessage}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
{{on "click" this.resetAndClose}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={{this.cannotZap}}
|
||||||
|
{{on "click" this.performZap}}
|
||||||
|
>
|
||||||
|
Zap
|
||||||
|
{{this.formattedAmount}}
|
||||||
|
sats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Fetching Invoice }}
|
||||||
|
{{#if (eq this.step "fetching-invoice")}}
|
||||||
|
<div class="centered">
|
||||||
|
<Icon @name="loading-ring" @size={{32}} class="spin-animation" />
|
||||||
|
<p>Creating zap request and fetching invoice...</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Awaiting Payment }}
|
||||||
|
{{#if (eq this.step "awaiting-payment")}}
|
||||||
|
<p class="zap-pay-prompt">
|
||||||
|
Scan to pay
|
||||||
|
<strong>{{this.formattedAmount}} sats</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<canvas {{qrCode this.invoice}}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.hasWebLN}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-full webln-pay-btn"
|
||||||
|
{{on "click" this.performWebLNPay}}
|
||||||
|
>
|
||||||
|
Open in Lightning wallet
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<a href={{this.lightningUri}} class="btn btn-primary btn-full">
|
||||||
|
Open in Lightning wallet
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-full copy-invoice-btn"
|
||||||
|
{{on "click" this.copyInvoice}}
|
||||||
|
>
|
||||||
|
Copy payment request
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{#if this.paymentNotDetected}}
|
||||||
|
<p class="meta-info">
|
||||||
|
Payment not detected yet. Keep the QR code open and try paying
|
||||||
|
again, or use a different Lightning wallet.
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Paying with Wallet }}
|
||||||
|
{{#if (eq this.step "paying-with-wallet")}}
|
||||||
|
<div class="centered">
|
||||||
|
<Icon @name="loading-ring" @size={{32}} class="spin-animation" />
|
||||||
|
<p>Paying invoice with WebLN...</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Wallet Pay Error }}
|
||||||
|
{{#if (eq this.step "wallet-pay-error")}}
|
||||||
|
<div class="alert alert-error">{{this.error}}</div>
|
||||||
|
<p class="meta-info">
|
||||||
|
The WebLN payment failed. You can still scan the QR code with a
|
||||||
|
Lightning wallet.
|
||||||
|
</p>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
{{on "click" this.resetAndClose}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
{{on "click" this.showInvoice}}
|
||||||
|
>
|
||||||
|
Show Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Success }}
|
||||||
|
{{#if (eq this.step "success")}}
|
||||||
|
<div class="success zap-success">
|
||||||
|
<div class="success-icon">⚡</div>
|
||||||
|
<h4>Zap Sent!</h4>
|
||||||
|
<p>
|
||||||
|
You zapped
|
||||||
|
<strong>{{this.formattedAmount}} sats</strong>
|
||||||
|
{{#if this.message}}
|
||||||
|
with message: "{{this.message}}"
|
||||||
|
{{/if}}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
{{on "click" this.resetAndClose}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{! Step: Error }}
|
||||||
|
{{#if (eq this.step "error")}}
|
||||||
|
<div class="alert alert-error">{{this.error}}</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
{{on "click" this.resetAndClose}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
{{on "click" this.retry}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Controller from '@ember/controller';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
|
export default class ContributionsController extends Controller {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service contributions;
|
||||||
|
@service storage;
|
||||||
|
|
||||||
|
loadContributionsTask = task({ restartable: true }, async (pubkey) => {
|
||||||
|
if (!pubkey) return;
|
||||||
|
await this.contributions.load(pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
get scrollTop() {
|
||||||
|
return this.mapUi.getScrollPosition('contributions');
|
||||||
|
}
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
return this.contributions.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return this.nostrAuth.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectContribution(item) {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const sidebarContent = document.querySelector('.sidebar-content');
|
||||||
|
if (sidebarContent) {
|
||||||
|
this.mapUi.saveScrollPosition('contributions', sidebarContent.scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mapUi.returnToRoute = { name: 'contributions' };
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
|
||||||
|
this.router.transitionTo(`/place/${item.placeIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onNostrConnected() {
|
||||||
|
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
backToMenu() {
|
||||||
|
this.router.transitionTo('menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Controller from '@ember/controller';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
|
function getPlaceTime(place) {
|
||||||
|
const dateVal = place.createdAt;
|
||||||
|
if (!dateVal) return 0;
|
||||||
|
if (typeof dateVal === 'number') {
|
||||||
|
return dateVal;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(dateVal);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ListsListController extends Controller {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
@service storage;
|
||||||
|
|
||||||
|
@tracked model;
|
||||||
|
@tracked loadedPlaces = [];
|
||||||
|
|
||||||
|
get listId() {
|
||||||
|
return this.model?.list_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||||
|
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||||
|
try {
|
||||||
|
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load places in list', listId, e);
|
||||||
|
this.loadedPlaces = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
get scrollTop() {
|
||||||
|
return this.mapUi.getScrollPosition(`list-${this.listId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get listColor() {
|
||||||
|
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
|
if (list && list.color) {
|
||||||
|
return list.color;
|
||||||
|
}
|
||||||
|
return getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--default-list-color')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
get listTitle() {
|
||||||
|
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
|
return list ? list.title : 'Collections';
|
||||||
|
}
|
||||||
|
|
||||||
|
get places() {
|
||||||
|
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
|
const placeRefsIds = new Set(
|
||||||
|
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter live tracked savedPlaces that are in this list
|
||||||
|
const livePlaces = this.storage.savedPlaces.filter((p) =>
|
||||||
|
placeRefsIds.has(p.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
// Process live state first to reflect deletions/edits immediately
|
||||||
|
livePlaces.forEach((p) => {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supplement with any background-fetched places that are still valid but not in live state yet
|
||||||
|
this.loadedPlaces.forEach((p) => {
|
||||||
|
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectPlace(place) {
|
||||||
|
if (place) {
|
||||||
|
const sidebarContent = document.querySelector('.sidebar-content');
|
||||||
|
if (sidebarContent) {
|
||||||
|
this.mapUi.saveScrollPosition(
|
||||||
|
`list-${this.listId}`,
|
||||||
|
sidebarContent.scrollTop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.mapUi.returnToRoute = {
|
||||||
|
name: 'lists.list',
|
||||||
|
model: this.listId,
|
||||||
|
};
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.router.transitionTo('place', place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
backToLists() {
|
||||||
|
this.router.transitionTo('lists.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { getDistance } from '../utils/geo';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
|
@service osm;
|
||||||
|
@service photon;
|
||||||
|
@service mapUi;
|
||||||
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||||
|
|
||||||
lat = null;
|
lat = null;
|
||||||
@@ -8,4 +18,195 @@ export default class SearchController extends Controller {
|
|||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
category = null;
|
category = null;
|
||||||
|
|
||||||
|
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||||
|
// 1. Check if the incoming parameters match our currently loaded search
|
||||||
|
const isSameSearch =
|
||||||
|
this.mapUi.currentSearch &&
|
||||||
|
params.q === this.mapUi.currentSearch.q &&
|
||||||
|
params.category === this.mapUi.currentSearch.category &&
|
||||||
|
params.lat === this.mapUi.currentSearch.lat &&
|
||||||
|
params.lon === this.mapUi.currentSearch.lon;
|
||||||
|
|
||||||
|
const hasResults =
|
||||||
|
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
|
||||||
|
|
||||||
|
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
||||||
|
if (isSameSearch && hasResults) {
|
||||||
|
if (this.mapUi.isSidebarVisible) {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
|
||||||
|
this.mapUi.hideSidebar();
|
||||||
|
this.mapUi.clearSearchResults();
|
||||||
|
|
||||||
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
|
let pois = [];
|
||||||
|
let loadingType = null;
|
||||||
|
let loadingValue = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Case 0: Category Search (category parameter present)
|
||||||
|
if (params.category && lat && lon) {
|
||||||
|
loadingType = 'category';
|
||||||
|
loadingValue = params.category;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// We need bounds. If we have active map state, use it.
|
||||||
|
let bounds = this.mapUi.currentBounds;
|
||||||
|
|
||||||
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
|
// or just use a fixed box around the center.
|
||||||
|
if (!bounds) {
|
||||||
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
|
// Let's take a safe box of ~1km radius.
|
||||||
|
const delta = 0.01;
|
||||||
|
bounds = {
|
||||||
|
minLat: lat - delta,
|
||||||
|
maxLat: lat + delta,
|
||||||
|
minLon: lon - delta,
|
||||||
|
maxLon: lon + delta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pois = await this.osm.getCategoryPois(
|
||||||
|
bounds,
|
||||||
|
params.category,
|
||||||
|
lat,
|
||||||
|
lon
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by distance from center
|
||||||
|
pois = pois
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
// Case 1: Text Search (q parameter present)
|
||||||
|
else if (params.q) {
|
||||||
|
loadingType = 'text';
|
||||||
|
loadingValue = params.q;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
|
// Search local bookmarks by name (minimum 3 characters)
|
||||||
|
const queryLower = params.q.toLowerCase();
|
||||||
|
let localMatches = [];
|
||||||
|
if (queryLower.length >= 3) {
|
||||||
|
localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
return (
|
||||||
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Case 2: Nearby Search (lat/lon present, no q)
|
||||||
|
else if (lat && lon) {
|
||||||
|
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||||
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
|
// Fetch POIs from Overpass
|
||||||
|
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance from click
|
||||||
|
pois = pois
|
||||||
|
.map((p) => {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search request failed.', error);
|
||||||
|
this.toast.show('Search request failed. Please try again.');
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (loadingType && loadingValue) {
|
||||||
|
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any of these are already bookmarked
|
||||||
|
// We resolve them to the bookmark version if they exist
|
||||||
|
pois = pois.map((p) => {
|
||||||
|
const saved = this.storage.findPlaceById(p.osmId);
|
||||||
|
return saved || p;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetName = params.selected || params.q;
|
||||||
|
|
||||||
|
if (targetName && pois.length > 0) {
|
||||||
|
// 1. Exact Name Match
|
||||||
|
let matchedPlace = pois.find(
|
||||||
|
(p) =>
|
||||||
|
p.osmTags &&
|
||||||
|
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||||
|
// Note: MapComponent had logic for <=20m + type match.
|
||||||
|
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||||
|
// For now, let's stick to name or very close proximity.
|
||||||
|
if (!matchedPlace) {
|
||||||
|
const topCandidate = pois[0];
|
||||||
|
if (topCandidate._distance <= 10) {
|
||||||
|
matchedPlace = topCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedPlace) {
|
||||||
|
// Direct transition!
|
||||||
|
this.router.replaceWith('place', matchedPlace);
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mapUi.setSearchResults(pois);
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
import { capitalize as format } from '../utils/format-text';
|
||||||
|
|
||||||
|
export default helper(function capitalize([text]) {
|
||||||
|
return format(text);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
import { formatRelativeDate as format } from '../utils/format-text';
|
||||||
|
|
||||||
|
export default helper(function formatRelativeDate([timestamp]) {
|
||||||
|
return format(timestamp);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
|
||||||
|
export default helper(function includes([collection, value]) {
|
||||||
|
if (!Array.isArray(collection)) return false;
|
||||||
|
return collection.includes(value);
|
||||||
|
});
|
||||||
@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
|
|||||||
|
|
||||||
let observer;
|
let observer;
|
||||||
|
|
||||||
|
const hideBlurhash = () => {
|
||||||
|
const parent = element.parentElement;
|
||||||
|
const slide =
|
||||||
|
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
|
||||||
|
|
||||||
|
// Only hide the blurhash if we're in the gallery-main view.
|
||||||
|
// In the inline view, we want to keep the blurhash visible behind portrait photos
|
||||||
|
// to fill the 16:9 container gracefully.
|
||||||
|
if (slide && slide.closest('.photo-carousel.gallery-main')) {
|
||||||
|
const blur = slide.querySelector('.place-header-photo-blur');
|
||||||
|
if (blur) {
|
||||||
|
blur.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
// Only apply the fade-in animation if it wasn't already loaded instantly
|
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||||
if (!element.classList.contains('loaded-instant')) {
|
if (!element.classList.contains('loaded-instant')) {
|
||||||
element.classList.add('loaded');
|
element.classList.add('loaded');
|
||||||
}
|
}
|
||||||
|
hideBlurhash();
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener('load', handleLoad);
|
element.addEventListener('load', handleLoad);
|
||||||
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
|
|||||||
if (img.complete) {
|
if (img.complete) {
|
||||||
// Already in browser cache, skip the animation
|
// Already in browser cache, skip the animation
|
||||||
element.classList.add('loaded-instant');
|
element.classList.add('loaded-instant');
|
||||||
|
hideBlurhash();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier((element, [scrollTop]) => {
|
||||||
|
if (element && typeof scrollTop === 'number' && scrollTop > 0) {
|
||||||
|
// Restore inside requestAnimationFrame to guarantee layout rendering is ready
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.scrollTop = scrollTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,6 +10,11 @@ Router.map(function () {
|
|||||||
this.route('place', { path: '/place/:place_id' });
|
this.route('place', { path: '/place/:place_id' });
|
||||||
this.route('place.new', { path: '/place/new' });
|
this.route('place.new', { path: '/place/new' });
|
||||||
this.route('search');
|
this.route('search');
|
||||||
|
this.route('menu');
|
||||||
|
this.route('lists', function () {
|
||||||
|
this.route('list', { path: '/:list_id' });
|
||||||
|
});
|
||||||
|
this.route('contributions');
|
||||||
this.route('oauth', function () {
|
this.route('oauth', function () {
|
||||||
this.route('osm-callback', { path: '/osm/callback' });
|
this.route('osm-callback', { path: '/osm/callback' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class ContributionsRoute extends Route {
|
||||||
|
@service mapUi;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service contributions;
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
super.setupController(controller, model);
|
||||||
|
if (controller && controller.loadContributionsTask) {
|
||||||
|
controller.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.contributions.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@ export default class IndexRoute extends Route {
|
|||||||
|
|
||||||
activate() {
|
activate() {
|
||||||
this.mapUi.clearSearchResults();
|
this.mapUi.clearSearchResults();
|
||||||
|
this.mapUi.hideSidebar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class ListsRoute extends Route {
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
|
||||||
|
export default class ListsIndexRoute extends Route {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class ListsListRoute extends Route {
|
||||||
|
@service storage;
|
||||||
|
|
||||||
|
model(params) {
|
||||||
|
// Resolve instantly so transition happens in 0ms!
|
||||||
|
return { list_id: params.list_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
console.debug('DEBUG: setupController controller is:', controller);
|
||||||
|
console.debug(
|
||||||
|
'DEBUG: controller.loadPlacesTask is:',
|
||||||
|
controller?.loadPlacesTask
|
||||||
|
);
|
||||||
|
controller.model = model;
|
||||||
|
super.setupController(controller, model);
|
||||||
|
if (controller && controller.loadPlacesTask) {
|
||||||
|
controller.loadPlacesTask.perform(model.list_id);
|
||||||
|
} else {
|
||||||
|
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class MenuRoute extends Route {
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-4
@@ -1,6 +1,23 @@
|
|||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
// Detects whether the fresh OSM data differs from the currently-shown model
|
||||||
|
// by more than ~1m of coordinate drift, or in any tag. Mirrors the relevant
|
||||||
|
// subset of `storage.refreshPlace`'s diff for the non-bookmark path.
|
||||||
|
function hasOsmChanges(place, fresh) {
|
||||||
|
const latDiff = Math.abs((place.lat ?? 0) - (fresh.lat ?? 0));
|
||||||
|
const lonDiff = Math.abs((place.lon ?? 0) - (fresh.lon ?? 0));
|
||||||
|
if (latDiff > 0.00001 || lonDiff > 0.00001) return true;
|
||||||
|
|
||||||
|
const oldTags = place.osmTags || {};
|
||||||
|
const newTags = fresh.osmTags || {};
|
||||||
|
const allKeys = new Set([...Object.keys(oldTags), ...Object.keys(newTags)]);
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (oldTags[key] !== newTags[key]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export default class PlaceRoute extends Route {
|
export default class PlaceRoute extends Route {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -96,6 +113,7 @@ export default class PlaceRoute extends Route {
|
|||||||
if (model) {
|
if (model) {
|
||||||
const options = { preventZoom: this.mapUi.preventNextZoom };
|
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||||
this.mapUi.selectPlace(model, options);
|
this.mapUi.selectPlace(model, options);
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.mapUi.preventNextZoom = false;
|
this.mapUi.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||||
@@ -107,6 +125,7 @@ export default class PlaceRoute extends Route {
|
|||||||
this.mapUi.clearSelection();
|
this.mapUi.clearSelection();
|
||||||
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
||||||
this.mapUi.returnToSearch = false;
|
this.mapUi.returnToSearch = false;
|
||||||
|
this.mapUi.returnToRoute = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOsmPlace(id, type = null) {
|
async loadOsmPlace(id, type = null) {
|
||||||
@@ -129,14 +148,32 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkUpdates(place) {
|
async checkUpdates(place) {
|
||||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
if (!place || !place.osmId || !place.osmType) return;
|
||||||
if (place && place.id && place.osmId && place.osmType) {
|
|
||||||
|
// Bookmarked place — refresh via storage, which persists the update.
|
||||||
|
if (place.id) {
|
||||||
const updatedPlace = await this.storage.refreshPlace(place);
|
const updatedPlace = await this.storage.refreshPlace(place);
|
||||||
if (updatedPlace) {
|
if (updatedPlace) {
|
||||||
// If an update occurred, refresh the map UI selection without moving the camera
|
// If an update occurred, refresh the map UI selection without moving
|
||||||
// This ensures the sidebar shows the new data
|
// the camera. This ensures the sidebar shows the new data.
|
||||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-bookmarked explicit OSM place — fetch fresh and update the model
|
||||||
|
// in-place if anything changed, so the sidebar reflects the latest OSM
|
||||||
|
// data without requiring a re-open.
|
||||||
|
try {
|
||||||
|
const fresh = await this.osm.fetchOsmObject(place.osmId, place.osmType, {
|
||||||
|
forceFresh: true,
|
||||||
|
});
|
||||||
|
if (fresh && hasOsmChanges(place, fresh)) {
|
||||||
|
Object.assign(place, fresh);
|
||||||
|
this.mapUi.selectPlace(place, { preventZoom: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('[place] Fresh fetch failed for', place.osmId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
|
|||||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||||
}
|
}
|
||||||
this.mapUi.startCreating();
|
this.mapUi.startCreating();
|
||||||
|
this.mapUi.showSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate() {
|
deactivate() {
|
||||||
|
|||||||
+14
-176
@@ -1,14 +1,9 @@
|
|||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { getDistance } from '../utils/geo';
|
|
||||||
|
|
||||||
export default class SearchRoute extends Route {
|
export default class SearchRoute extends Route {
|
||||||
@service osm;
|
|
||||||
@service photon;
|
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service storage;
|
|
||||||
@service router;
|
|
||||||
@service toast;
|
@service toast;
|
||||||
|
|
||||||
queryParams = {
|
queryParams = {
|
||||||
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
|
|||||||
category: { refreshModel: true },
|
category: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
model(params) {
|
||||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
// Just return params, doing the async fetch in the controller
|
||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
return params;
|
||||||
let pois = [];
|
|
||||||
let loadingType = null;
|
|
||||||
let loadingValue = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Case 0: Category Search (category parameter present)
|
|
||||||
if (params.category && lat && lon) {
|
|
||||||
loadingType = 'category';
|
|
||||||
loadingValue = params.category;
|
|
||||||
this.mapUi.startLoading(loadingType, loadingValue);
|
|
||||||
|
|
||||||
// We need bounds. If we have active map state, use it.
|
|
||||||
let bounds = this.mapUi.currentBounds;
|
|
||||||
|
|
||||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
|
||||||
// or just use a fixed box around the center.
|
|
||||||
if (!bounds) {
|
|
||||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
|
||||||
// Let's take a safe box of ~1km radius.
|
|
||||||
const delta = 0.01;
|
|
||||||
bounds = {
|
|
||||||
minLat: lat - delta,
|
|
||||||
maxLat: lat + delta,
|
|
||||||
minLon: lon - delta,
|
|
||||||
maxLon: lon + delta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pois = await this.osm.getCategoryPois(
|
|
||||||
bounds,
|
|
||||||
params.category,
|
|
||||||
lat,
|
|
||||||
lon
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by distance from center
|
|
||||||
pois = pois
|
|
||||||
.map((p) => ({
|
|
||||||
...p,
|
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
}
|
|
||||||
// Case 1: Text Search (q parameter present)
|
|
||||||
else if (params.q) {
|
|
||||||
loadingType = 'text';
|
|
||||||
loadingValue = params.q;
|
|
||||||
this.mapUi.startLoading(loadingType, loadingValue);
|
|
||||||
|
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
|
||||||
|
|
||||||
// Search local bookmarks by name
|
|
||||||
const queryLower = params.q.toLowerCase();
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
return (
|
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
|
||||||
else if (lat && lon) {
|
|
||||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
|
||||||
const searchRadius = 50; // Default radius
|
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
|
||||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
|
||||||
|
|
||||||
// Get cached/saved places in search radius
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
|
||||||
return dist <= searchRadius;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from click
|
|
||||||
pois = pois
|
|
||||||
.map((p) => {
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (loadingType && loadingValue) {
|
|
||||||
this.mapUi.stopLoading(loadingType, loadingValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
|
||||||
// We resolve them to the bookmark version if they exist
|
|
||||||
pois = pois.map((p) => {
|
|
||||||
const saved = this.storage.findPlaceById(p.osmId);
|
|
||||||
return saved || p;
|
|
||||||
});
|
|
||||||
|
|
||||||
return pois;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterModel(model, transition) {
|
|
||||||
const { q, selected } = transition.to.queryParams;
|
|
||||||
|
|
||||||
// Heuristic Match Logic (ported from MapComponent)
|
|
||||||
// If 'selected' is provided (from map click), try to find that specific feature.
|
|
||||||
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
|
||||||
const targetName = selected || q;
|
|
||||||
|
|
||||||
if (targetName && model.length > 0) {
|
|
||||||
let matchedPlace = null;
|
|
||||||
|
|
||||||
// 1. Exact Name Match
|
|
||||||
matchedPlace = model.find(
|
|
||||||
(p) =>
|
|
||||||
p.osmTags &&
|
|
||||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
|
||||||
// Note: MapComponent had logic for <=20m + type match.
|
|
||||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
|
||||||
// For now, let's stick to name or very close proximity.
|
|
||||||
if (!matchedPlace) {
|
|
||||||
const topCandidate = model[0];
|
|
||||||
if (topCandidate._distance <= 10) {
|
|
||||||
matchedPlace = topCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedPlace) {
|
|
||||||
// Direct transition!
|
|
||||||
this.router.replaceWith('place', matchedPlace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop the pulse animation since search is done (and we are staying here)
|
|
||||||
this.mapUi.stopSearch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupController(controller, model) {
|
setupController(controller, model) {
|
||||||
super.setupController(controller, model);
|
super.setupController(controller, model);
|
||||||
// Ensure pulse is stopped if we reach here
|
|
||||||
this.mapUi.stopSearch();
|
// Trigger the background task to fetch results
|
||||||
this.mapUi.setSearchResults(model);
|
controller.fetchResultsTask.perform(model);
|
||||||
|
|
||||||
// Store current search params to allow "Up" navigation from place details
|
// Store current search params to allow "Up" navigation from place details
|
||||||
const { q, category, lat, lon } = this.paramsFor('search');
|
const { q, category, lat, lon } = this.paramsFor('search');
|
||||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetController(controller, isExiting) {
|
||||||
|
if (isExiting) {
|
||||||
|
controller.fetchResultsTask.cancelAll();
|
||||||
|
this.mapUi.stopSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
error(error, transition) {
|
error(error, transition) {
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
|
|||||||
if (transition) {
|
if (transition) {
|
||||||
transition.abort();
|
transition.abort();
|
||||||
}
|
}
|
||||||
return false; // Prevent bubble and stop transition
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-15
@@ -35,23 +35,20 @@ export default class BlossomService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _getAuthHeader(action, hash, serverUrl) {
|
async _getAuthHeader(action, hash, serverUrl) {
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const serverHostname = new URL(serverUrl).hostname;
|
const serverHostname = new URL(serverUrl).hostname;
|
||||||
|
|
||||||
const authTemplate = {
|
const authEvent = await EventFactory.fromKind(24242)
|
||||||
kind: 24242,
|
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
|
||||||
created_at: now,
|
.modifyPublicTags(() => [
|
||||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
|
||||||
tags: [
|
|
||||||
['t', action],
|
['t', action],
|
||||||
['x', hash],
|
['x', hash],
|
||||||
['expiration', String(now + 3600)],
|
['expiration', String(now + 3600)],
|
||||||
['server', serverHostname],
|
['server', serverHostname],
|
||||||
],
|
])
|
||||||
};
|
.created(now)
|
||||||
|
.as(this.nostrAuth.signer)
|
||||||
const authEvent = await factory.sign(authTemplate);
|
.sign();
|
||||||
const base64 = btoa(JSON.stringify(authEvent));
|
const base64 = btoa(JSON.stringify(authEvent));
|
||||||
const base64url = base64
|
const base64url = base64
|
||||||
.replace(/\+/g, '-')
|
.replace(/\+/g, '-')
|
||||||
@@ -60,10 +57,13 @@ export default class BlossomService extends Service {
|
|||||||
return `Nostr ${base64url}`;
|
return `Nostr ${base64url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _uploadToServer(file, hash, serverUrl) {
|
async _uploadToServer(file, hash, serverUrl, onProgress) {
|
||||||
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
||||||
|
|
||||||
|
if (onProgress) onProgress('signing');
|
||||||
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
||||||
|
|
||||||
|
if (onProgress) onProgress('uploading');
|
||||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -109,14 +109,20 @@ export default class BlossomService extends Service {
|
|||||||
|
|
||||||
if (options.sequential) {
|
if (options.sequential) {
|
||||||
// Sequential upload logic
|
// Sequential upload logic
|
||||||
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
|
mainResult = await this._uploadToServer(
|
||||||
|
file,
|
||||||
|
payloadHash,
|
||||||
|
mainServer,
|
||||||
|
options.onProgress
|
||||||
|
);
|
||||||
|
|
||||||
for (const serverUrl of fallbackServers) {
|
for (const serverUrl of fallbackServers) {
|
||||||
try {
|
try {
|
||||||
const result = await this._uploadToServer(
|
const result = await this._uploadToServer(
|
||||||
file,
|
file,
|
||||||
payloadHash,
|
payloadHash,
|
||||||
serverUrl
|
serverUrl,
|
||||||
|
options.onProgress
|
||||||
);
|
);
|
||||||
fallbackUrls.push(result.url);
|
fallbackUrls.push(result.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,9 +131,14 @@ export default class BlossomService extends Service {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Concurrent upload logic
|
// Concurrent upload logic
|
||||||
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
|
const mainPromise = this._uploadToServer(
|
||||||
|
file,
|
||||||
|
payloadHash,
|
||||||
|
mainServer,
|
||||||
|
options.onProgress
|
||||||
|
);
|
||||||
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||||
this._uploadToServer(file, payloadHash, serverUrl)
|
this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main server MUST succeed
|
// Main server MUST succeed
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { groupPhotoContributions } from '../utils/contributions';
|
||||||
|
|
||||||
|
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||||
|
* timeline entries, and resolving place names from bookmarks or the OSM API.
|
||||||
|
*
|
||||||
|
* The flow is:
|
||||||
|
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
|
||||||
|
* 2. Group events into contribution entries via `groupPhotoContributions`.
|
||||||
|
* 3. Resolve place names from bookmarks (sync, instant) on the first render,
|
||||||
|
* then asynchronously from the IndexedDB name cache and OSM cache.
|
||||||
|
* 4. For unresolved names, batch-fetch from the OSM API in the background.
|
||||||
|
* 5. Any items that can't be resolved get a fallback name so they don't stay
|
||||||
|
* stuck in a "Loading…" state forever.
|
||||||
|
* 6. Update `@tracked items` so the UI renders progressively.
|
||||||
|
*
|
||||||
|
* The persistent name cache is stored in IndexedDB (via the `localForage`
|
||||||
|
* service) with one key per `placeIdentifier`, so partial updates don't
|
||||||
|
* require re-serializing the whole map.
|
||||||
|
*/
|
||||||
|
export default class ContributionsService extends Service {
|
||||||
|
@service nostrData;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service storage;
|
||||||
|
@service osm;
|
||||||
|
@service localForage;
|
||||||
|
|
||||||
|
@tracked items = [];
|
||||||
|
|
||||||
|
_sub = null;
|
||||||
|
_pendingBatchPromise = null;
|
||||||
|
_lastBatchSignature = '';
|
||||||
|
_unresolvable = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async name resolution. Checks, in order:
|
||||||
|
* 1. Bookmarks (sync, instant).
|
||||||
|
* 2. The persistent IndexedDB name cache (per-entry key).
|
||||||
|
* 3. The OSM service's IndexedDB cache (from place detail visits).
|
||||||
|
*
|
||||||
|
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
async _resolveCachedName(entry) {
|
||||||
|
// 1. Try bookmarks (instant)
|
||||||
|
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||||
|
if (bookmark?.title) return bookmark.title;
|
||||||
|
|
||||||
|
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
|
||||||
|
const cachedName = await this.localForage.get(
|
||||||
|
NAME_CACHE_STORE,
|
||||||
|
entry.placeIdentifier
|
||||||
|
);
|
||||||
|
if (cachedName) return cachedName;
|
||||||
|
|
||||||
|
// 3. Try OSM IndexedDB cache (from place detail visits)
|
||||||
|
const cached = await this.osm.getCachedOsmObject(
|
||||||
|
entry.osmType,
|
||||||
|
entry.osmId
|
||||||
|
);
|
||||||
|
return cached?.title || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously resolves names for still-loading entries from the caches.
|
||||||
|
* Falls through to `_maybeBatchFetchNames` for anything that remains
|
||||||
|
* unresolved. Fire-and-forget from `_updateItems` so the list renders
|
||||||
|
* immediately with bookmark-resolved names.
|
||||||
|
*
|
||||||
|
* @param {Array} entries
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _resolveFromCache(entries) {
|
||||||
|
const pending = entries.filter((e) => e.placeNameLoading);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
this._maybeBatchFetchNames(this.items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pending.map(async (entry) => {
|
||||||
|
const name = await this._resolveCachedName(entry);
|
||||||
|
if (name) {
|
||||||
|
entry.placeName = name;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render with whatever resolved, then kick off the network batch for
|
||||||
|
// entries that are still loading.
|
||||||
|
this.items = [...this.items];
|
||||||
|
this._maybeBatchFetchNames(this.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the user's contributions and subscribes to live updates.
|
||||||
|
*
|
||||||
|
* @param {string} pubkey The user's Nostr pubkey
|
||||||
|
*/
|
||||||
|
async load(pubkey) {
|
||||||
|
if (!pubkey) {
|
||||||
|
this.items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to the user's own kind 360 events. Each update triggers a re-group
|
||||||
|
// and place-name resolution. This mirrors the `loadPhotosForPlace` pattern.
|
||||||
|
this._sub = this.nostrData.store
|
||||||
|
.timeline([{ kinds: [360, 5], authors: [pubkey] }])
|
||||||
|
.subscribe((events) => {
|
||||||
|
this._updateItems(events);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also kick off the network fetch via nostrData. This populates the store and
|
||||||
|
// causes the subscription above to fire with fresh events.
|
||||||
|
await this.nostrData.loadMyContributions(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the subscription and clears the timeline. Called when leaving the
|
||||||
|
* contributions route.
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this._sub) {
|
||||||
|
this._sub.unsubscribe();
|
||||||
|
this._sub = null;
|
||||||
|
}
|
||||||
|
this.items = [];
|
||||||
|
this._lastBatchSignature = '';
|
||||||
|
this._pendingBatchPromise = null;
|
||||||
|
this._unresolvable.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
this.stop();
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateItems(events) {
|
||||||
|
// 1. Group events into contribution entries (newest-first)
|
||||||
|
const entries = groupPhotoContributions(events);
|
||||||
|
|
||||||
|
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||||
|
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||||
|
if (bookmark?.title) {
|
||||||
|
entry.placeName = bookmark.title;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items = entries;
|
||||||
|
|
||||||
|
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||||
|
// cache, then fall through to the network batch for the rest.
|
||||||
|
void this._resolveFromCache(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isUnresolvable(entry) {
|
||||||
|
return this._unresolvable.has(entry.placeIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
_maybeBatchFetchNames(entries) {
|
||||||
|
const unresolved = entries.filter(
|
||||||
|
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply fallbacks for items already marked unresolvable in this session
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||||
|
entry.placeName = this._fallbackName(entry);
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolved.length === 0) {
|
||||||
|
if (this.items.some((i) => !i.placeNameLoading)) {
|
||||||
|
this.items = [...this.items];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a stable signature so we only fire one batch request per unique set
|
||||||
|
const signature = unresolved
|
||||||
|
.map((e) => e.placeIdentifier)
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastBatchSignature = signature;
|
||||||
|
|
||||||
|
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||||
|
.then((nameMap) => {
|
||||||
|
// Merge resolved names back into the current `items`.
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (!item.placeNameLoading) continue;
|
||||||
|
if (nameMap.has(item.placeIdentifier)) {
|
||||||
|
const name = nameMap.get(item.placeIdentifier);
|
||||||
|
item.placeName = name;
|
||||||
|
item.placeNameLoading = false;
|
||||||
|
} else {
|
||||||
|
// Could not be resolved (deleted object, network error for this item).
|
||||||
|
// Mark as unresolvable for this session and use a fallback.
|
||||||
|
this._unresolvable.add(item.placeIdentifier);
|
||||||
|
item.placeName = this._fallbackName(item);
|
||||||
|
item.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trigger a re-render
|
||||||
|
this.items = [...this.items];
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[contributions] Batch name resolution failed', e);
|
||||||
|
// On a total failure, mark all as unresolvable and apply fallbacks
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (item.placeNameLoading) {
|
||||||
|
this._unresolvable.add(item.placeIdentifier);
|
||||||
|
item.placeName = this._fallbackName(item);
|
||||||
|
item.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.items = [...this.items];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this._pendingBatchPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_fallbackName(item) {
|
||||||
|
return `OSM ${item.osmType} ${item.osmId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _batchResolveNames(entries) {
|
||||||
|
const nameMap = new Map();
|
||||||
|
const toFetch = [];
|
||||||
|
|
||||||
|
// Re-check cache in case it was populated between the trigger and now
|
||||||
|
for (const entry of entries) {
|
||||||
|
const cached = await this._resolveCachedName(entry);
|
||||||
|
if (cached) {
|
||||||
|
nameMap.set(entry.placeIdentifier, cached);
|
||||||
|
} else {
|
||||||
|
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.length === 0) return nameMap;
|
||||||
|
|
||||||
|
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
||||||
|
|
||||||
|
// Persist each newly-resolved name to the IndexedDB name cache as its own
|
||||||
|
// entry (per-placeIdentifier key) so we don't re-fetch next session.
|
||||||
|
const writePromises = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||||
|
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||||
|
const place = places.get(cacheKey);
|
||||||
|
if (place?.title) {
|
||||||
|
nameMap.set(entry.placeIdentifier, place.title);
|
||||||
|
writePromises.push(
|
||||||
|
this.localForage.set(
|
||||||
|
NAME_CACHE_STORE,
|
||||||
|
entry.placeIdentifier,
|
||||||
|
place.title
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(writePromises);
|
||||||
|
return nameMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ export default class ImageProcessorService extends Service {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to process image: ${e.message}`);
|
throw new Error(`Failed to process image: ${e.message}`, { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import localforage from 'localforage';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
|
const DB_NAME = 'marco';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin async-only wrapper around `localforage` that exposes namespaced
|
||||||
|
* object stores under a single IndexedDB database (`marco`).
|
||||||
|
*
|
||||||
|
* Each "store" is a `localforage` instance created with a unique
|
||||||
|
* `storeName`, so callers can keep data isolated (e.g. OSM cache vs.
|
||||||
|
* contributions name cache) without managing their own connections.
|
||||||
|
*
|
||||||
|
* All methods return Promises — IndexedDB is inherently async, so callers
|
||||||
|
* must `await` every read/write.
|
||||||
|
*/
|
||||||
|
export default class LocalForageService extends Service {
|
||||||
|
_instances = new Map();
|
||||||
|
|
||||||
|
_instance(storeName) {
|
||||||
|
let instance = this._instances.get(storeName);
|
||||||
|
if (!instance) {
|
||||||
|
instance = localforage.createInstance({
|
||||||
|
name: DB_NAME,
|
||||||
|
storeName,
|
||||||
|
});
|
||||||
|
this._instances.set(storeName, instance);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(storeName, key) {
|
||||||
|
return this._instance(storeName).getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(storeName, key, value) {
|
||||||
|
return this._instance(storeName).setItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(storeName, key) {
|
||||||
|
return this._instance(storeName).removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(storeName) {
|
||||||
|
return this._instance(storeName).keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(storeName) {
|
||||||
|
return this._instance(storeName).clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async iterate(storeName, fn) {
|
||||||
|
return this._instance(storeName).iterate(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class MapUiService extends Service {
|
export default class MapUiService extends Service {
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
@@ -9,14 +10,45 @@ export default class MapUiService extends Service {
|
|||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
|
@tracked returnToRoute = null;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
@tracked currentBounds = null;
|
@tracked currentBounds = null;
|
||||||
|
@tracked currentZoom = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
@tracked selectionOptions = {};
|
@tracked selectionOptions = {};
|
||||||
@tracked preventNextZoom = false;
|
@tracked preventNextZoom = false;
|
||||||
@tracked searchResults = [];
|
@tracked searchResults = [];
|
||||||
@tracked currentSearch = null;
|
@tracked currentSearch = null;
|
||||||
@tracked loadingState = null;
|
@tracked loadingState = null;
|
||||||
|
@tracked isSidebarVisible = false;
|
||||||
|
@tracked isSidebarOpening = false;
|
||||||
|
|
||||||
|
scrollPositions = {};
|
||||||
|
|
||||||
|
@action
|
||||||
|
saveScrollPosition(key, value) {
|
||||||
|
this.scrollPositions[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
getScrollPosition(key) {
|
||||||
|
return this.scrollPositions[key] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSidebar() {
|
||||||
|
if (!this.isSidebarVisible) {
|
||||||
|
this.isSidebarVisible = true;
|
||||||
|
this.isSidebarOpening = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isSidebarOpening = false;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSidebar() {
|
||||||
|
this.isSidebarVisible = false;
|
||||||
|
this.isSidebarOpening = false;
|
||||||
|
}
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -72,6 +104,10 @@ export default class MapUiService extends Service {
|
|||||||
this.currentCenter = { lat, lon };
|
this.currentCenter = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateZoom(zoom) {
|
||||||
|
this.currentZoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
updateBounds(bounds) {
|
updateBounds(bounds) {
|
||||||
this.currentBounds = bounds;
|
this.currentBounds = bounds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||||
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||||
|
|
||||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
const DEFAULT_CONNECT_RELAY = 'wss://nostr.kosmos.org';
|
||||||
|
|
||||||
import { isMobile } from '../utils/device';
|
import { isMobile } from '../utils/device';
|
||||||
|
|
||||||
@@ -150,9 +150,6 @@ export default class NostrAuthService extends Service {
|
|||||||
const relay = DEFAULT_CONNECT_RELAY;
|
const relay = DEFAULT_CONNECT_RELAY;
|
||||||
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, 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({
|
this._signerInstance = new NostrConnectSigner({
|
||||||
pool: this.nostrRelay.pool,
|
pool: this.nostrRelay.pool,
|
||||||
relays: [relay],
|
relays: [relay],
|
||||||
@@ -235,9 +232,6 @@ export default class NostrAuthService extends Service {
|
|||||||
|
|
||||||
const localSigner = this._getLocalSigner();
|
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({
|
this._signerInstance = new NostrConnectSigner({
|
||||||
pool: this.nostrRelay.pool,
|
pool: this.nostrRelay.pool,
|
||||||
relays: [relay],
|
relays: [relay],
|
||||||
|
|||||||
+305
-38
@@ -1,18 +1,25 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { EMPTY, from } from 'rxjs';
|
||||||
import { EventStore } from 'applesauce-core/event-store';
|
import { EventStore } from 'applesauce-core/event-store';
|
||||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
|
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||||
import { NostrIDB, openDB } from 'nostr-idb';
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
import { normalizeRelayUrl } from '../utils/nostr';
|
import {
|
||||||
|
excludeRequiredRelays,
|
||||||
|
mergeRequiredRelays,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
uniqNormalizedRelays,
|
||||||
|
} from '../utils/nostr';
|
||||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||||
|
|
||||||
const DIRECTORY_RELAYS = [
|
const DIRECTORY_RELAYS = [
|
||||||
'wss://purplepag.es',
|
'wss://relay.primal.net',
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
|
'wss://relay.damus.io',
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||||
@@ -29,19 +36,43 @@ export default class NostrDataService extends Service {
|
|||||||
@tracked mailboxes = null;
|
@tracked mailboxes = null;
|
||||||
@tracked blossomServers = [];
|
@tracked blossomServers = [];
|
||||||
@tracked placePhotos = [];
|
@tracked placePhotos = [];
|
||||||
|
@tracked myContributionEvents = [];
|
||||||
|
@tracked profiles = {};
|
||||||
|
@tracked zapReceipts = {};
|
||||||
|
|
||||||
_profileSub = null;
|
_profileSub = null;
|
||||||
_mailboxesSub = null;
|
_mailboxesSub = null;
|
||||||
_blossomSub = null;
|
_blossomSub = null;
|
||||||
_photosSub = null;
|
_photosSub = null;
|
||||||
|
_contributionsSub = null;
|
||||||
|
_profileModelSubs = new Map();
|
||||||
|
|
||||||
|
_zapReceiptsSub = null;
|
||||||
|
_zapReceiptsNetworkSub = null;
|
||||||
|
_zapRefreshTimer = null;
|
||||||
|
_lastPhotoIds = new Set();
|
||||||
|
|
||||||
_requestSub = null;
|
_requestSub = null;
|
||||||
_cachePromise = null;
|
_cachePromise = null;
|
||||||
|
_currentPlaceEntityId = null;
|
||||||
loadedGeohashPrefixes = new Set();
|
loadedGeohashPrefixes = new Set();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
|
|
||||||
|
// Set up the event loader synchronously so that any subscription
|
||||||
|
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
|
||||||
|
// before the IndexedDB cache has finished opening. The cacheRequest
|
||||||
|
// is lazy — it returns EMPTY until `this.cache` is available, so the
|
||||||
|
// loader falls through to relay hints → lookup relays in the meantime.
|
||||||
|
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
|
||||||
|
cacheRequest: (filters) => {
|
||||||
|
if (!this.cache) return EMPTY;
|
||||||
|
return from(this.cache.query(filters));
|
||||||
|
},
|
||||||
|
lookupRelays: DIRECTORY_RELAYS,
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize the IndexedDB cache
|
// Initialize the IndexedDB cache
|
||||||
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||||
this.cache = new NostrIDB(db, {
|
this.cache = new NostrIDB(db, {
|
||||||
@@ -55,13 +86,15 @@ export default class NostrDataService extends Service {
|
|||||||
this._stopPersisting = persistEventsToCache(
|
this._stopPersisting = persistEventsToCache(
|
||||||
this.store,
|
this.store,
|
||||||
async (events) => {
|
async (events) => {
|
||||||
// Only cache profiles, mailboxes, blossom servers, and place photos
|
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||||
const toCache = events.filter(
|
const toCache = events.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.kind === 0 ||
|
e.kind === 0 ||
|
||||||
|
e.kind === 5 ||
|
||||||
e.kind === 10002 ||
|
e.kind === 10002 ||
|
||||||
e.kind === 10063 ||
|
e.kind === 10063 ||
|
||||||
e.kind === 360
|
e.kind === 360 ||
|
||||||
|
e.kind === 9735
|
||||||
);
|
);
|
||||||
|
|
||||||
if (toCache.length > 0) {
|
if (toCache.length > 0) {
|
||||||
@@ -82,43 +115,62 @@ export default class NostrDataService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultReadRelays() {
|
get requiredReadRelays() {
|
||||||
const mailboxes = (this.mailboxes?.inboxes || [])
|
return DEFAULT_READ_RELAYS;
|
||||||
.map(normalizeRelayUrl)
|
|
||||||
.filter(Boolean);
|
|
||||||
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
|
||||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultWriteRelays() {
|
get requiredWriteRelays() {
|
||||||
const mailboxes = (this.mailboxes?.outboxes || [])
|
return DEFAULT_WRITE_RELAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
get mailboxReadRelays() {
|
||||||
|
return (this.mailboxes?.inboxes || [])
|
||||||
.map(normalizeRelayUrl)
|
.map(normalizeRelayUrl)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const defaults =
|
}
|
||||||
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
|
||||||
return Array.from(new Set([...defaults, ...mailboxes]));
|
get mailboxWriteRelays() {
|
||||||
|
return (this.mailboxes?.outboxes || [])
|
||||||
|
.map(normalizeRelayUrl)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
get configuredReadRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxReadRelays,
|
||||||
|
...(this.settings.nostrReadRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrReadRelayExclusions || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get configuredWriteRelays() {
|
||||||
|
const configured = uniqNormalizedRelays([
|
||||||
|
...this.mailboxWriteRelays,
|
||||||
|
...(this.settings.nostrWriteRelays || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return excludeRequiredRelays(
|
||||||
|
configured,
|
||||||
|
this.settings.nostrWriteRelayExclusions || []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeReadRelays() {
|
get activeReadRelays() {
|
||||||
if (this.settings.nostrReadRelays) {
|
return mergeRequiredRelays(
|
||||||
return Array.from(
|
this.requiredReadRelays,
|
||||||
new Set(
|
this.configuredReadRelays
|
||||||
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.defaultReadRelays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeWriteRelays() {
|
get activeWriteRelays() {
|
||||||
if (this.settings.nostrWriteRelays) {
|
return mergeRequiredRelays(
|
||||||
return Array.from(
|
this.requiredWriteRelays,
|
||||||
new Set(
|
this.configuredWriteRelays
|
||||||
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.defaultWriteRelays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
@@ -185,19 +237,36 @@ export default class NostrDataService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadPhotosForPlace(place) {
|
async loadPhotosForPlace(place) {
|
||||||
|
const entityId =
|
||||||
|
place && place.osmId && place.osmType
|
||||||
|
? `osm:${place.osmType}:${place.osmId}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Skip the full reset if we're loading the same place again (e.g. from
|
||||||
|
// checkUpdates calling selectPlace a second time). This prevents tearing
|
||||||
|
// down timeline and profile subscriptions that are still in-flight.
|
||||||
|
if (entityId && entityId === this._currentPlaceEntityId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._photosSub) {
|
if (this._photosSub) {
|
||||||
this._photosSub.unsubscribe();
|
this._photosSub.unsubscribe();
|
||||||
this._photosSub = null;
|
this._photosSub = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.placePhotos = [];
|
this._cleanupZapReceiptSubs();
|
||||||
|
this._clearZapRefreshTimer();
|
||||||
|
|
||||||
if (!place || !place.osmId || !place.osmType) {
|
this.placePhotos = [];
|
||||||
|
this.zapReceipts = {};
|
||||||
|
this._lastPhotoIds = new Set();
|
||||||
|
this._clearProfileSubs();
|
||||||
|
this._currentPlaceEntityId = entityId;
|
||||||
|
|
||||||
|
if (!entityId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
|
||||||
|
|
||||||
// Setup reactive store query
|
// Setup reactive store query
|
||||||
this._photosSub = this.store
|
this._photosSub = this.store
|
||||||
.timeline([
|
.timeline([
|
||||||
@@ -208,6 +277,9 @@ export default class NostrDataService extends Service {
|
|||||||
])
|
])
|
||||||
.subscribe((events) => {
|
.subscribe((events) => {
|
||||||
this.placePhotos = events;
|
this.placePhotos = events;
|
||||||
|
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||||
|
this.loadProfiles(pubkeys);
|
||||||
|
this._scheduleZapReceiptRefresh(events);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -215,7 +287,7 @@ export default class NostrDataService extends Service {
|
|||||||
|
|
||||||
const cachedEvents = await this.cache.query([
|
const cachedEvents = await this.cache.query([
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -236,7 +308,7 @@ export default class NostrDataService extends Service {
|
|||||||
this.nostrRelay.pool
|
this.nostrRelay.pool
|
||||||
.request(this.activeReadRelays, [
|
.request(this.activeReadRelays, [
|
||||||
{
|
{
|
||||||
kinds: [360],
|
kinds: [360, 5],
|
||||||
'#i': [entityId],
|
'#i': [entityId],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -253,6 +325,84 @@ export default class NostrDataService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadMyContributions(pubkey) {
|
||||||
|
if (!pubkey) return;
|
||||||
|
|
||||||
|
// Reset state and unsubscribe from any previous subscription
|
||||||
|
this.myContributionEvents = [];
|
||||||
|
if (this._contributionsSub) {
|
||||||
|
this._contributionsSub.unsubscribe();
|
||||||
|
this._contributionsSub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
|
||||||
|
|
||||||
|
// 1. Set up reactive store subscription so the timeline updates as events arrive
|
||||||
|
this._contributionsSub = this.store
|
||||||
|
.timeline(filters)
|
||||||
|
.subscribe((events) => {
|
||||||
|
this.myContributionEvents = events;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Populate the store from the local Nostr IDB cache (instant)
|
||||||
|
try {
|
||||||
|
await this._cachePromise;
|
||||||
|
|
||||||
|
const cachedEvents = await this.cache.query(filters);
|
||||||
|
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Request fresh events from the network in the background
|
||||||
|
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(
|
||||||
|
'[nostr-data] Error fetching my contribution events:',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfiles(pubkeys) {
|
||||||
|
const newPubkeys = pubkeys.filter(
|
||||||
|
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const pubkey of newPubkeys) {
|
||||||
|
const sub = this.store
|
||||||
|
.model(ProfileModel, pubkey)
|
||||||
|
.subscribe((profileContent) => {
|
||||||
|
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||||
|
});
|
||||||
|
this._profileModelSubs.set(pubkey, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile(pubkey) {
|
||||||
|
return this.profiles[pubkey];
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearProfileSubs() {
|
||||||
|
for (const sub of this._profileModelSubs.values()) {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
this._profileModelSubs.clear();
|
||||||
|
this.profiles = {};
|
||||||
|
}
|
||||||
|
|
||||||
async loadProfile(pubkey) {
|
async loadProfile(pubkey) {
|
||||||
if (!pubkey) return;
|
if (!pubkey) return;
|
||||||
|
|
||||||
@@ -356,6 +506,116 @@ export default class NostrDataService extends Service {
|
|||||||
return 'Not connected';
|
return 'Not connected';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache() {
|
||||||
|
await this._cachePromise;
|
||||||
|
if (this.cache) {
|
||||||
|
await this.cache.deleteAllEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleZapReceiptRefresh(events) {
|
||||||
|
const newIds = new Set(events.map((e) => e.id));
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (newIds.size !== this._lastPhotoIds.size) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
for (const id of newIds) {
|
||||||
|
if (!this._lastPhotoIds.has(id)) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
this._lastPhotoIds = newIds;
|
||||||
|
|
||||||
|
this._clearZapRefreshTimer();
|
||||||
|
this._zapRefreshTimer = setTimeout(() => {
|
||||||
|
this._zapRefreshTimer = null;
|
||||||
|
this._refreshZapReceiptSubscription([...newIds]);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearZapRefreshTimer() {
|
||||||
|
if (this._zapRefreshTimer) {
|
||||||
|
clearTimeout(this._zapRefreshTimer);
|
||||||
|
this._zapRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshZapReceiptSubscription(photoIds) {
|
||||||
|
this._cleanupZapReceiptSubs();
|
||||||
|
|
||||||
|
if (!photoIds || photoIds.length === 0) return;
|
||||||
|
|
||||||
|
// Batch IDs into filters of <=100 to stay under relay REQ limits
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
const filters = [];
|
||||||
|
for (let i = 0; i < photoIds.length; i += BATCH_SIZE) {
|
||||||
|
filters.push({
|
||||||
|
kinds: [9735],
|
||||||
|
'#e': photoIds.slice(i, i + BATCH_SIZE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive local query — emits whenever matching events are in the store
|
||||||
|
this._zapReceiptsSub = this.store.timeline(filters).subscribe((events) => {
|
||||||
|
this._updateZapReceipts(events);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load from IDB cache (fire-and-forget — store.add triggers timeline)
|
||||||
|
this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.then((cachedEvents) => {
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn('[nostr-data] Failed to read zap receipts from cache', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network request (fire-and-forget)
|
||||||
|
this._zapReceiptsNetworkSub = this.nostrRelay.pool
|
||||||
|
.request(this.activeReadRelays, filters)
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('[nostr-data] Error fetching zap receipts:', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateZapReceipts(events) {
|
||||||
|
const grouped = {};
|
||||||
|
for (const receipt of events) {
|
||||||
|
for (const tag of receipt.tags) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
|
if (!grouped[tag[1]]) grouped[tag[1]] = [];
|
||||||
|
grouped[tag[1]].push(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.zapReceipts = { ...this.zapReceipts, ...grouped };
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupZapReceiptSubs() {
|
||||||
|
if (this._zapReceiptsSub) {
|
||||||
|
this._zapReceiptsSub.unsubscribe();
|
||||||
|
this._zapReceiptsSub = null;
|
||||||
|
}
|
||||||
|
if (this._zapReceiptsNetworkSub) {
|
||||||
|
this._zapReceiptsNetworkSub.unsubscribe();
|
||||||
|
this._zapReceiptsNetworkSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_cleanupSubscriptions() {
|
_cleanupSubscriptions() {
|
||||||
if (this._requestSub) {
|
if (this._requestSub) {
|
||||||
this._requestSub.unsubscribe();
|
this._requestSub.unsubscribe();
|
||||||
@@ -377,11 +637,18 @@ export default class NostrDataService extends Service {
|
|||||||
this._photosSub.unsubscribe();
|
this._photosSub.unsubscribe();
|
||||||
this._photosSub = null;
|
this._photosSub = null;
|
||||||
}
|
}
|
||||||
|
if (this._contributionsSub) {
|
||||||
|
this._contributionsSub.unsubscribe();
|
||||||
|
this._contributionsSub = null;
|
||||||
|
}
|
||||||
|
this._cleanupZapReceiptSubs();
|
||||||
|
this._clearZapRefreshTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
willDestroy() {
|
willDestroy() {
|
||||||
super.willDestroy(...arguments);
|
super.willDestroy(...arguments);
|
||||||
this._cleanupSubscriptions();
|
this._cleanupSubscriptions();
|
||||||
|
this._clearProfileSubs();
|
||||||
|
|
||||||
if (this._stopPersisting) {
|
if (this._stopPersisting) {
|
||||||
this._stopPersisting();
|
this._stopPersisting();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Service from '@ember/service';
|
|||||||
import { RelayPool } from 'applesauce-relay';
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
export default class NostrRelayService extends Service {
|
export default class NostrRelayService extends Service {
|
||||||
pool = new RelayPool();
|
pool = new RelayPool({ enablePing: true });
|
||||||
|
|
||||||
async publish(relays, event) {
|
async publish(relays, event) {
|
||||||
if (!relays || relays.length === 0) {
|
if (!relays || relays.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { ZapRequestFactory } from 'applesauce-common/factories';
|
||||||
|
import {
|
||||||
|
getInvoice,
|
||||||
|
parseBolt11,
|
||||||
|
parseLNURLOrAddress,
|
||||||
|
} from 'applesauce-common/helpers';
|
||||||
|
import { getInboxes } from 'applesauce-core/helpers/mailboxes';
|
||||||
|
import { firstValueFrom, timeout, catchError, of } from 'rxjs';
|
||||||
|
|
||||||
|
const DEFAULT_ZAP_RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECTORY_RELAYS = [
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
];
|
||||||
|
|
||||||
|
const RELAY_FETCH_TIMEOUT = 10_000;
|
||||||
|
|
||||||
|
export default class NostrZapService extends Service {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service nostrRelay;
|
||||||
|
|
||||||
|
@tracked recipientRelays = null;
|
||||||
|
|
||||||
|
getLightningAddress(pubkey) {
|
||||||
|
const profile = this.nostrData.getProfile(pubkey);
|
||||||
|
return profile?.lud16 || profile?.lud06 || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLnurl(address) {
|
||||||
|
const url = parseLNURLOrAddress(address);
|
||||||
|
if (!url) throw new Error('Invalid lightning address or LNURL');
|
||||||
|
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch LNURL pay endpoint: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.callback) {
|
||||||
|
throw new Error('Invalid LNURL pay endpoint: missing callback');
|
||||||
|
}
|
||||||
|
if (!data.allowsNostr) {
|
||||||
|
throw new Error('This lightning address does not support Nostr zaps');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
callback: data.callback,
|
||||||
|
minSendable: data.minSendable ?? 1000,
|
||||||
|
maxSendable: data.maxSendable ?? 100_000_000_000,
|
||||||
|
allowsNostr: !!data.allowsNostr,
|
||||||
|
nostrPubkey: data.nostrPubkey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRecipientRelays(pubkey) {
|
||||||
|
if (!pubkey) return this._fallbackRelays();
|
||||||
|
|
||||||
|
// Check the store first — the event may already be cached
|
||||||
|
const fromStore = await this._getMailboxesFromStore(pubkey);
|
||||||
|
if (fromStore?.length) {
|
||||||
|
this.recipientRelays = fromStore;
|
||||||
|
return fromStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from network if not in the store
|
||||||
|
const fetched = await this._fetchMailboxesFromNetwork(pubkey);
|
||||||
|
if (fetched?.length) {
|
||||||
|
this.recipientRelays = fetched;
|
||||||
|
return fetched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: sender's inbox relays + popular defaults
|
||||||
|
const fallback = this._fallbackRelays();
|
||||||
|
this.recipientRelays = fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getMailboxesFromStore(pubkey) {
|
||||||
|
try {
|
||||||
|
const event = await firstValueFrom(
|
||||||
|
this.nostrData.store.replaceable(10002, pubkey).pipe(
|
||||||
|
timeout(500),
|
||||||
|
catchError(() => of(null))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (event) return getInboxes(event);
|
||||||
|
} catch {
|
||||||
|
// Event not in store yet
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchMailboxesFromNetwork(pubkey) {
|
||||||
|
const relays = this.nostrData.activeReadRelays?.length
|
||||||
|
? this.nostrData.activeReadRelays
|
||||||
|
: DIRECTORY_RELAYS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await firstValueFrom(
|
||||||
|
this.nostrRelay.pool
|
||||||
|
.request(relays, [{ kinds: [10002], authors: [pubkey] }])
|
||||||
|
.pipe(
|
||||||
|
timeout(RELAY_FETCH_TIMEOUT),
|
||||||
|
catchError(() => of(null))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (event) {
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
return getInboxes(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network fetch failed
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fallbackRelays() {
|
||||||
|
const senderRelays = this.nostrData.mailboxReadRelays || [];
|
||||||
|
return [...new Set([...senderRelays, ...DEFAULT_ZAP_RELAYS])].slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
getZapRelays() {
|
||||||
|
if (this.recipientRelays?.length) {
|
||||||
|
return this.recipientRelays.slice(0, 5);
|
||||||
|
}
|
||||||
|
return this._fallbackRelays();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createZapRequest(photo, amountMsats, message) {
|
||||||
|
const event = this.nostrData.store.getEvent(photo.eventId);
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('Photo event not found in store');
|
||||||
|
}
|
||||||
|
|
||||||
|
const relays = this.getZapRelays();
|
||||||
|
const signer = this.nostrAuth.signer;
|
||||||
|
if (!signer) {
|
||||||
|
throw new Error('Nostr signer not available. Please connect Nostr.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ZapRequestFactory.event(event, amountMsats, relays)
|
||||||
|
.message(message || '')
|
||||||
|
.as(signer)
|
||||||
|
.sign();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchInvoice(callbackUrl, zapRequest, amountMsats) {
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
url.searchParams.set('amount', amountMsats.toString());
|
||||||
|
url.searchParams.set('nostr', JSON.stringify(zapRequest));
|
||||||
|
return await getInvoice(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInvoice(invoice) {
|
||||||
|
return parseBolt11(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeForZapReceipt(zapRequest, photo, onReceipt) {
|
||||||
|
const eventId = photo.eventId;
|
||||||
|
const relays = this.getZapRelays();
|
||||||
|
const since = Math.floor(Date.now() / 1000) - 10;
|
||||||
|
|
||||||
|
return this.nostrRelay.pool
|
||||||
|
.subscription(relays, {
|
||||||
|
kinds: [9735],
|
||||||
|
'#e': [eventId],
|
||||||
|
since,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
const descTag = event.tags.find((t) => t[0] === 'description');
|
||||||
|
if (!descTag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = JSON.parse(descTag[1]);
|
||||||
|
if (req.id === zapRequest.id) {
|
||||||
|
onReceipt(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON in description tag
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('[nostr-zap] Receipt subscription error:', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasWebLN() {
|
||||||
|
return typeof window !== 'undefined' && typeof window.webln !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
async payWithWebln(invoice) {
|
||||||
|
if (!this.hasWebLN()) throw new Error('WebLN not available');
|
||||||
|
await window.webln.enable();
|
||||||
|
return await window.webln.sendPayment(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zap(photo, amountMsats, message) {
|
||||||
|
const pubkey = photo?.pubkey;
|
||||||
|
if (!pubkey) throw new Error('Photo has no pubkey');
|
||||||
|
|
||||||
|
// Ensure recipient relays are loaded before building the zap request
|
||||||
|
if (!this.recipientRelays) {
|
||||||
|
await this.loadRecipientRelays(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.getLightningAddress(pubkey);
|
||||||
|
if (!address) {
|
||||||
|
throw new Error(
|
||||||
|
'This user has no lightning address set in their profile'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lnurl = await this.resolveLnurl(address);
|
||||||
|
|
||||||
|
if (amountMsats < lnurl.minSendable || amountMsats > lnurl.maxSendable) {
|
||||||
|
throw new Error(
|
||||||
|
`Amount must be between ${lnurl.minSendable} and ${lnurl.maxSendable} millisats`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zapRequest = await this.createZapRequest(photo, amountMsats, message);
|
||||||
|
const invoice = await this.fetchInvoice(
|
||||||
|
lnurl.callback,
|
||||||
|
zapRequest,
|
||||||
|
amountMsats
|
||||||
|
);
|
||||||
|
|
||||||
|
return { invoice, zapRequest };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ export default class OsmAuthService extends Service {
|
|||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
redirectUrl: redirectUrl,
|
redirectUrl: redirectUrl,
|
||||||
storeRefreshToken: true,
|
storeRefreshToken: true,
|
||||||
|
onAccessTokenExpiry() {
|
||||||
|
return this.exchangeRefreshTokenForAccessToken();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
new MarcoOsmAuthStorage()
|
new MarcoOsmAuthStorage()
|
||||||
);
|
);
|
||||||
|
|||||||
+230
-20
@@ -4,12 +4,110 @@ import { getCategoryById } from '../utils/poi-categories';
|
|||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service localForage;
|
||||||
|
|
||||||
controller = null;
|
controller = null;
|
||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
cachedPlaces = new Map();
|
cachedPlaces = new Map();
|
||||||
|
|
||||||
|
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
|
||||||
|
// `localForage` service) so that names and basic info survive across
|
||||||
|
// sessions and can be rendered instantly without waiting on the OSM API.
|
||||||
|
// Entries are refreshed in the background.
|
||||||
|
static STORE_NAME = 'osm-cache';
|
||||||
|
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
|
||||||
|
|
||||||
|
_buildCacheKey(osmType, osmId) {
|
||||||
|
return `${osmType}:${osmId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readLocalCache(osmType, osmId) {
|
||||||
|
const key = this._buildCacheKey(osmType, osmId);
|
||||||
|
try {
|
||||||
|
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed.timestamp !== 'number' ||
|
||||||
|
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeLocalCache(osmType, osmId, data) {
|
||||||
|
if (!data) return;
|
||||||
|
const key = this._buildCacheKey(osmType, osmId);
|
||||||
|
try {
|
||||||
|
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('[osm] Failed to write IndexedDB cache entry', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async lookup for an OSM place. Checks the short-lived in-memory cache
|
||||||
|
* first, then the persistent IndexedDB cache. Returns `null` if not
|
||||||
|
* cached.
|
||||||
|
*
|
||||||
|
* Use this for instant rendering (e.g. place names in a list) and fall
|
||||||
|
* back to `fetchOsmObject` for a fresh fetch + background refresh.
|
||||||
|
*
|
||||||
|
* @param {string} osmType 'node' | 'way' | 'relation'
|
||||||
|
* @param {string} osmId
|
||||||
|
* @returns {Promise<object|null>} Normalized OSM place data
|
||||||
|
*/
|
||||||
|
async getCachedOsmObject(osmType, osmId) {
|
||||||
|
if (!osmType || !osmId) return null;
|
||||||
|
|
||||||
|
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||||
|
const memoryEntry = this.cachedPlaces.get(cacheKey);
|
||||||
|
if (
|
||||||
|
memoryEntry &&
|
||||||
|
Date.now() - memoryEntry.timestamp < OsmService.IN_MEMORY_TTL_MS
|
||||||
|
) {
|
||||||
|
return memoryEntry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._readLocalCache(osmType, osmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data) {
|
||||||
|
if (!data || !data.title) {
|
||||||
|
console.debug(
|
||||||
|
`[osm] Skipping cache write for ${cacheKey}: data has no title`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedPlaces.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
// Auto-evict from the short-lived in-memory cache so it doesn't grow unbounded.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.cachedPlaces.delete(cacheKey);
|
||||||
|
}, OsmService.IN_MEMORY_TTL_MS);
|
||||||
|
|
||||||
|
// Fire-and-forget the persistent write — the in-memory cache covers the
|
||||||
|
// next immediate read, and the IndexedDB write happens in the background.
|
||||||
|
void this._writeLocalCache(osmType, osmId, data);
|
||||||
|
}
|
||||||
|
|
||||||
cancelAll() {
|
cancelAll() {
|
||||||
if (this.controller) {
|
if (this.controller) {
|
||||||
this.controller.abort();
|
this.controller.abort();
|
||||||
@@ -230,16 +328,57 @@ out center;
|
|||||||
return this.normalizePoi(data.elements[0]);
|
return this.normalizePoi(data.elements[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchOsmObject(osmId, osmType) {
|
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
|
||||||
if (!osmId || !osmType) return null;
|
if (!osmId || !osmType) return null;
|
||||||
|
|
||||||
const cacheKey = `${osmType}:${osmId}`;
|
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||||
|
|
||||||
|
// Force a fresh fetch from the API, bypassing the cache. Used by callers
|
||||||
|
// that need genuinely current data (e.g. storage.refreshPlace, which
|
||||||
|
// diffs the bookmark against freshly-fetched OSM data).
|
||||||
|
if (forceFresh) {
|
||||||
|
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached-first path: return the in-memory entry if it's still warm.
|
||||||
const cached = this.cachedPlaces.get(cacheKey);
|
const cached = this.cachedPlaces.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise return the persistent IndexedDB entry and refresh in the
|
||||||
|
// background so the next visit is fresh.
|
||||||
|
const localCached = await this._readLocalCache(osmType, osmId);
|
||||||
|
if (localCached) {
|
||||||
|
console.debug(`Using IndexedDB cached OSM object for ${cacheKey}`);
|
||||||
|
// Refresh in the background, but don't block the caller.
|
||||||
|
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
|
||||||
|
console.debug('[osm] Background refresh failed for', cacheKey, e);
|
||||||
|
});
|
||||||
|
return localCached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _refreshOsmObject(osmId, osmType, cacheKey) {
|
||||||
|
const fresh = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||||
|
if (fresh) {
|
||||||
|
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, fresh);
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchAndCacheOsmObject(osmId, osmType, cacheKey) {
|
||||||
|
const data = await this._fetchOsmObjectFromApi(osmId, osmType);
|
||||||
|
if (data) {
|
||||||
|
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchOsmObjectFromApi(osmId, osmType) {
|
||||||
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`;
|
||||||
@@ -263,29 +402,100 @@ out center;
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const normalizedData = this.normalizeOsmApiData(
|
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-fetches multiple OSM objects, using cached data where available and
|
||||||
|
* only making network requests for uncached IDs.
|
||||||
|
*
|
||||||
|
* @param {Array<{osmType: string, osmId: string}>} items
|
||||||
|
* @returns {Promise<Map<string, object>>} Map keyed by `${osmType}:${osmId}`
|
||||||
|
*/
|
||||||
|
async fetchOsmObjectsBatch(items) {
|
||||||
|
const result = new Map();
|
||||||
|
if (!items || items.length === 0) return result;
|
||||||
|
|
||||||
|
// 1. Collect whatever is already cached
|
||||||
|
const missing = [];
|
||||||
|
for (const { osmType, osmId } of items) {
|
||||||
|
if (!osmType || !osmId) continue;
|
||||||
|
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||||
|
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||||
|
if (cached) {
|
||||||
|
result.set(cacheKey, cached);
|
||||||
|
} else {
|
||||||
|
missing.push({ osmType, osmId, cacheKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length === 0) return result;
|
||||||
|
|
||||||
|
// 2. Group missing items by OSM type for batched API requests
|
||||||
|
const byType = new Map();
|
||||||
|
for (const item of missing) {
|
||||||
|
if (!byType.has(item.osmType)) byType.set(item.osmType, []);
|
||||||
|
byType.get(item.osmType).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch each type group from the OSM API (one request per type)
|
||||||
|
const fetchPromises = [];
|
||||||
|
for (const [osmType, group] of byType) {
|
||||||
|
fetchPromises.push(this._fetchOsmObjectsByType(osmType, group, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(fetchPromises);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchOsmObjectsByType(osmType, group, result) {
|
||||||
|
// OSM API supports fetching multiple elements of the same type in a single
|
||||||
|
// request using comma-separated IDs (max ~50 per request).
|
||||||
|
//
|
||||||
|
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||||
|
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
|
||||||
|
// do NOT write these results to the general OSM cache — only the returned
|
||||||
|
// Map is used by the caller (the contributions service manages its own
|
||||||
|
// name cache). This prevents incomplete data from breaking place detail
|
||||||
|
// navigation, which needs the full single-object endpoint.
|
||||||
|
const MAX_IDS_PER_REQUEST = 50;
|
||||||
|
|
||||||
|
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||||
|
const chunk = group.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||||
|
const idsParam = chunk.map((c) => c.osmId).join(',');
|
||||||
|
const plural = osmType === 'way' ? 'ways' : osmType + 's';
|
||||||
|
const url = `https://www.openstreetmap.org/api/0.6/${plural}.json?${plural}=${idsParam}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(
|
||||||
|
`[osm] Batch fetch failed for ${osmType} (${res.status})`,
|
||||||
|
idsParam
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
for (const item of chunk) {
|
||||||
|
const normalized = this.normalizeOsmApiData(
|
||||||
|
data.elements,
|
||||||
|
item.osmId,
|
||||||
|
osmType
|
||||||
|
);
|
||||||
|
if (normalized) {
|
||||||
|
result.set(item.cacheKey, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[osm] Batch fetch error for ${osmType}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizeOsmApiData(elements, targetId, targetType) {
|
normalizeOsmApiData(elements, targetId, targetType) {
|
||||||
if (!elements || elements.length === 0) return null;
|
if (!elements || elements.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
nostrPhotoFallbackUploads: false,
|
nostrPhotoFallbackUploads: false,
|
||||||
nostrReadRelays: null,
|
nostrReadRelays: null,
|
||||||
nostrWriteRelays: null,
|
nostrWriteRelays: null,
|
||||||
|
nostrReadRelayExclusions: null,
|
||||||
|
nostrWriteRelayExclusions: null,
|
||||||
|
experimentalEnablePhotoDeletion: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@@ -20,6 +23,11 @@ export default class SettingsService extends Service {
|
|||||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||||
|
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||||
|
@tracked nostrWriteRelayExclusions =
|
||||||
|
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||||
|
@tracked experimentalEnablePhotoDeletion =
|
||||||
|
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -108,6 +116,10 @@ export default class SettingsService extends Service {
|
|||||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||||
|
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||||
|
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||||
|
this.experimentalEnablePhotoDeletion =
|
||||||
|
finalSettings.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
// Save to ensure migrated settings are stored in the new format
|
// Save to ensure migrated settings are stored in the new format
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
@@ -122,6 +134,9 @@ export default class SettingsService extends Service {
|
|||||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||||
nostrReadRelays: this.nostrReadRelays,
|
nostrReadRelays: this.nostrReadRelays,
|
||||||
nostrWriteRelays: this.nostrWriteRelays,
|
nostrWriteRelays: this.nostrWriteRelays,
|
||||||
|
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||||
|
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||||
|
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||||
};
|
};
|
||||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
|
|||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service toast;
|
||||||
rs;
|
rs;
|
||||||
widget;
|
widget;
|
||||||
@tracked placesInView = [];
|
@tracked placesInView = [];
|
||||||
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
|
|||||||
@tracked connected = false;
|
@tracked connected = false;
|
||||||
@tracked userAddress = null;
|
@tracked userAddress = null;
|
||||||
@tracked isWidgetOpen = false;
|
@tracked isWidgetOpen = false;
|
||||||
|
isNewConnection = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
|
|
||||||
|
this.checkInitialConnectionState();
|
||||||
|
|
||||||
this.rs = new RemoteStorage({
|
this.rs = new RemoteStorage({
|
||||||
modules: [Places],
|
modules: [Places],
|
||||||
});
|
});
|
||||||
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
|
||||||
|
if (this.isNewConnection) {
|
||||||
|
this.toast.show('Remote storage connected', 3000);
|
||||||
|
this.isNewConnection = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.loadLists();
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
|
|||||||
this.loadedPrefixes = [];
|
this.loadedPrefixes = [];
|
||||||
this.lists = [];
|
this.lists = [];
|
||||||
this.initialSyncDone = false;
|
this.initialSyncDone = false;
|
||||||
|
this.isNewConnection = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('sync-done', () => {
|
this.rs.on('sync-done', () => {
|
||||||
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkInitialConnectionState() {
|
||||||
|
this.isNewConnection = true;
|
||||||
|
try {
|
||||||
|
if (window.localStorage) {
|
||||||
|
const keys = [
|
||||||
|
'remotestorage:wireclient',
|
||||||
|
'remotestorage:dropbox',
|
||||||
|
'remotestorage:googledrive',
|
||||||
|
];
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = window.localStorage.getItem(key);
|
||||||
|
if (data) {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed && parsed.token) {
|
||||||
|
this.isNewConnection = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to check localStorage for existing connection:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handlePlaceChange(event) {
|
handlePlaceChange(event) {
|
||||||
const { newValue, relativePath } = event;
|
const { newValue, relativePath } = event;
|
||||||
|
|
||||||
@@ -234,6 +270,11 @@ export default class StorageService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPlacesInList(listId) {
|
||||||
|
if (!this.places || !this.places.lists) return [];
|
||||||
|
return this.places.lists.getPlaces(listId);
|
||||||
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
// 1. Calculate required prefixes
|
// 1. Calculate required prefixes
|
||||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
@@ -385,7 +426,8 @@ export default class StorageService extends Service {
|
|||||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||||
const freshData = await this.osm.fetchOsmObject(
|
const freshData = await this.osm.fetchOsmObject(
|
||||||
place.osmId,
|
place.osmId,
|
||||||
place.osmType
|
place.osmType,
|
||||||
|
{ forceFresh: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!freshData) {
|
if (!freshData) {
|
||||||
|
|||||||
+792
-63
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,9 @@ import Component from '@glimmer/component';
|
|||||||
import { pageTitle } from 'ember-page-title';
|
import { pageTitle } from 'ember-page-title';
|
||||||
import Map from '#components/map';
|
import Map from '#components/map';
|
||||||
import AppHeader from '#components/app-header';
|
import AppHeader from '#components/app-header';
|
||||||
import AppMenu from '#components/app-menu/index';
|
|
||||||
import Toast from '#components/toast';
|
import Toast from '#components/toast';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { or } from 'ember-truth-helpers';
|
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
|
||||||
export default class ApplicationComponent extends Component {
|
export default class ApplicationComponent extends Component {
|
||||||
@@ -15,15 +12,18 @@ export default class ApplicationComponent extends Component {
|
|||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
|
|
||||||
@tracked isAppMenuOpen = false;
|
|
||||||
|
|
||||||
get isSidebarOpen() {
|
get isSidebarOpen() {
|
||||||
// We consider the sidebar "open" if we are in search or place routes.
|
// We consider the sidebar "open" if we are in search, menu, lists, contributions or place routes AND it's visible.
|
||||||
// This helps the map know if it should shift the center or adjust view.
|
// This helps the map know if it should shift the center or adjust view.
|
||||||
|
const name = this.router.currentRouteName;
|
||||||
return (
|
return (
|
||||||
this.router.currentRouteName === 'place' ||
|
this.mapUi.isSidebarVisible &&
|
||||||
this.router.currentRouteName === 'place.new' ||
|
(name === 'place' ||
|
||||||
this.router.currentRouteName === 'search'
|
name === 'place.new' ||
|
||||||
|
name === 'search' ||
|
||||||
|
name === 'menu' ||
|
||||||
|
name === 'contributions' ||
|
||||||
|
name.startsWith('lists'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,25 +36,40 @@ export default class ApplicationComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
toggleAppMenu() {
|
toggleAppMenu() {
|
||||||
this.isAppMenuOpen = !this.isAppMenuOpen;
|
if (this.router.currentRouteName === 'menu') {
|
||||||
}
|
this.router.transitionTo('index');
|
||||||
|
} else {
|
||||||
@action
|
this.router.transitionTo('menu');
|
||||||
closeAppMenu() {
|
}
|
||||||
this.isAppMenuOpen = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleOutsideClick() {
|
handleOutsideClick() {
|
||||||
if (this.isAppMenuOpen) {
|
const name = this.router.currentRouteName;
|
||||||
this.closeAppMenu();
|
if (
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
name === 'search' ||
|
||||||
this.router.transitionTo('index');
|
name === 'place' ||
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
name === 'menu' ||
|
||||||
// If in place route, decide if we want to go back to search or index
|
name === 'contributions' ||
|
||||||
// For now, let's go to index or maybe back to search if search params exist?
|
name.startsWith('lists')
|
||||||
// Simplest behavior: clear selection
|
) {
|
||||||
this.router.transitionTo('index');
|
this.mapUi.clearSelection();
|
||||||
|
this.mapUi.hideSidebar();
|
||||||
|
if (
|
||||||
|
name === 'menu' ||
|
||||||
|
name === 'contributions' ||
|
||||||
|
name.startsWith('lists')
|
||||||
|
) {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
} else if (name === 'place') {
|
||||||
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,32 +81,30 @@ export default class ApplicationComponent extends Component {
|
|||||||
<template>
|
<template>
|
||||||
{{pageTitle "Marco"}}
|
{{pageTitle "Marco"}}
|
||||||
|
|
||||||
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
<div class={{if this.mapUi.isSidebarOpening "sidebar-opening"}}>
|
||||||
|
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||||
|
|
||||||
<div
|
|
||||||
id="rs-widget-container"
|
|
||||||
class={{if this.storage.isWidgetOpen "visible"}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
{{#if this.storage.isWidgetOpen}}
|
|
||||||
<div
|
<div
|
||||||
class="rs-backdrop"
|
id="rs-widget-container"
|
||||||
role="button"
|
class={{if this.storage.isWidgetOpen "visible"}}
|
||||||
{{on "click" this.storage.closeWidget}}
|
|
||||||
></div>
|
></div>
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<Map
|
{{#if this.storage.isWidgetOpen}}
|
||||||
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
<div
|
||||||
@onOutsideClick={{this.handleOutsideClick}}
|
class="rs-backdrop"
|
||||||
/>
|
role="button"
|
||||||
|
{{on "click" this.storage.closeWidget}}
|
||||||
|
></div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.isAppMenuOpen}}
|
<Map
|
||||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
@isSidebarOpen={{this.isSidebarOpen}}
|
||||||
{{/if}}
|
@onOutsideClick={{this.handleOutsideClick}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
{{outlet}}
|
{{outlet}}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ContributionsTimeline from '#components/contributions-timeline';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if @controller.mapUi.isSidebarVisible}}
|
||||||
|
<ContributionsTimeline
|
||||||
|
@items={{@controller.items}}
|
||||||
|
@isLoading={{@controller.loadContributionsTask.isRunning}}
|
||||||
|
@isConnected={{@controller.isConnected}}
|
||||||
|
@scrollTop={{@controller.scrollTop}}
|
||||||
|
@onSelect={{@controller.selectContribution}}
|
||||||
|
@onBack={{@controller.backToMenu}}
|
||||||
|
@onClose={{@controller.close}}
|
||||||
|
@onNostrConnected={{@controller.onNostrConnected}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<template>{{outlet}}</template>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
|
||||||
|
export default class ListsIndexTemplate extends Component {
|
||||||
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
styleFor(color) {
|
||||||
|
const finalColor =
|
||||||
|
color ||
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--default-list-color')
|
||||||
|
.trim();
|
||||||
|
return htmlSafe(`background-color: ${finalColor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectList(listId) {
|
||||||
|
this.router.transitionTo('lists.list', listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
backToMenu() {
|
||||||
|
this.router.transitionTo('menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header has-back-btn">
|
||||||
|
<button type="button" class="back-btn" {{on "click" this.backToMenu}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2 class="sidebar-header-text-centered">
|
||||||
|
<span class="sidebar-header-icon-wrapper">
|
||||||
|
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||||
|
</span>
|
||||||
|
Collections
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" this.close}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<ul class="places-list">
|
||||||
|
{{#each this.storage.lists as |list|}}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lists-index-item"
|
||||||
|
{{on "click" (fn this.selectList list.id)}}
|
||||||
|
>
|
||||||
|
<div class="lists-index-item-left">
|
||||||
|
{{! template-lint-disable no-inline-styles }}
|
||||||
|
<span
|
||||||
|
class="list-color-dot"
|
||||||
|
style={{this.styleFor list.color}}
|
||||||
|
></span>
|
||||||
|
<div class="lists-index-name">{{list.title}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lists-index-count">
|
||||||
|
{{#if list.placeRefs.length}}
|
||||||
|
{{list.placeRefs.length}}
|
||||||
|
places
|
||||||
|
{{else}}
|
||||||
|
empty
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import PlacesSidebar from '#components/places-sidebar';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if @controller.mapUi.isSidebarVisible}}
|
||||||
|
<PlacesSidebar
|
||||||
|
@places={{@controller.places}}
|
||||||
|
@title={{@controller.listTitle}}
|
||||||
|
@color={{@controller.listColor}}
|
||||||
|
@scrollTop={{@controller.scrollTop}}
|
||||||
|
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||||
|
@onSelect={{@controller.selectPlace}}
|
||||||
|
@onClose={{@controller.close}}
|
||||||
|
@onBack={{@controller.backToLists}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import AppMenu from '#components/app-menu/index';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
|
export default class MenuTemplate extends Component {
|
||||||
|
@service router;
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
<template><AppMenu @onClose={{this.close}} /></template>
|
||||||
|
}
|
||||||
+31
-10
@@ -77,8 +77,19 @@ export default class PlaceTemplate extends Component {
|
|||||||
navigateBack(place) {
|
navigateBack(place) {
|
||||||
// The sidebar calls this with null when "Back" is clicked.
|
// The sidebar calls this with null when "Back" is clicked.
|
||||||
if (place === null) {
|
if (place === null) {
|
||||||
|
// If we have an active route context (e.g. lists), return to it
|
||||||
|
if (this.mapUi.returnToRoute) {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
const { name, model } = this.mapUi.returnToRoute;
|
||||||
|
if (model !== undefined) {
|
||||||
|
this.router.transitionTo(name, model);
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
// If we have an active search context, return to it (UP navigation)
|
// If we have an active search context, return to it (UP navigation)
|
||||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('search', {
|
this.router.transitionTo('search', {
|
||||||
queryParams: this.mapUi.currentSearch,
|
queryParams: this.mapUi.currentSearch,
|
||||||
});
|
});
|
||||||
@@ -88,23 +99,33 @@ export default class PlaceTemplate extends Component {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If a place is selected (unlikely in this view, but possible if we add related links)
|
// If a place is selected (unlikely in this view, but possible if we add related links)
|
||||||
|
this.mapUi.showSidebar();
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
// Clear search results so we don't fall back to the list
|
this.mapUi.clearSelection();
|
||||||
this.router.transitionTo('index');
|
this.mapUi.hideSidebar();
|
||||||
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PlacesSidebar
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
@selectedPlace={{this.place}}
|
<PlacesSidebar
|
||||||
@onClose={{this.close}}
|
@selectedPlace={{this.place}}
|
||||||
@onSelect={{this.navigateBack}}
|
@onClose={{this.close}}
|
||||||
@onBookmarkChange={{this.refreshMap}}
|
@onSelect={{this.navigateBack}}
|
||||||
@onUpdate={{this.handleUpdate}}
|
@onBookmarkChange={{this.refreshMap}}
|
||||||
/>
|
@onUpdate={{this.handleUpdate}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-20
@@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
<div class="sidebar-header">
|
<div class="sidebar">
|
||||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
<div class="sidebar-header">
|
||||||
New Place</h2>
|
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
New Place</h2>
|
||||||
@name="x"
|
<button
|
||||||
@size={{20}}
|
type="button"
|
||||||
@color="#333"
|
class="close-btn"
|
||||||
/></button>
|
{{on "click" this.close}}
|
||||||
</div>
|
><Icon @name="x" @size={{20}} @color="#333" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<p class="helper-text">
|
<p class="helper-text">
|
||||||
Drag the map to position the crosshair.
|
Drag the map to position the crosshair.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<PlaceEditForm
|
<PlaceEditForm
|
||||||
@place={{this.initialPlace}}
|
@place={{this.initialPlace}}
|
||||||
@onSave={{this.savePlace}}
|
@onSave={{this.savePlace}}
|
||||||
@onCancel={{this.close}}
|
@onCancel={{this.close}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export default class SearchTemplate extends Component {
|
|||||||
@action
|
@action
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
|
const sidebarContent = document.querySelector('.sidebar-content');
|
||||||
|
if (sidebarContent) {
|
||||||
|
this.mapUi.saveScrollPosition('search', sidebarContent.scrollTop);
|
||||||
|
}
|
||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
// We don't need to manually set currentSearch here because
|
// We don't need to manually set currentSearch here because
|
||||||
// it was already set in the route's setupController
|
// it was already set in the route's setupController
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
@@ -19,14 +25,17 @@ export default class SearchTemplate extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
this.router.transitionTo('index');
|
this.mapUi.hideSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PlacesSidebar
|
{{#if this.mapUi.isSidebarVisible}}
|
||||||
@places={{@model}}
|
<PlacesSidebar
|
||||||
@onSelect={{this.selectPlace}}
|
@places={{this.mapUi.searchResults}}
|
||||||
@onClose={{this.close}}
|
@scrollTop={{this.mapUi.getScrollPosition "search"}}
|
||||||
/>
|
@onSelect={{this.selectPlace}}
|
||||||
|
@onClose={{this.close}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Utilities for grouping the user's Nostr contribution events into timeline entries.
|
||||||
|
*
|
||||||
|
* A contribution "entry" groups one or more related Nostr events that are part of the
|
||||||
|
* same logical action (e.g. uploading several photos of the same place in quick
|
||||||
|
* succession). Entries are ordered newest-first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
const HOUR_IN_SECONDS = 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single contribution timeline entry.
|
||||||
|
*
|
||||||
|
* `placeName` and `placeNameLoading` are tracked so that mutating them after
|
||||||
|
* the entry has been rendered (e.g. when the background OSM batch fetch
|
||||||
|
* resolves a place name) re-renders the consuming component. The remaining
|
||||||
|
* fields are static data and do not need to be tracked.
|
||||||
|
*/
|
||||||
|
export class ContributionEntry {
|
||||||
|
type = 'photo';
|
||||||
|
placeIdentifier;
|
||||||
|
osmType;
|
||||||
|
osmId;
|
||||||
|
photos;
|
||||||
|
createdAt;
|
||||||
|
eventCount;
|
||||||
|
@tracked placeName = null;
|
||||||
|
@tracked placeNameLoading = true;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
placeIdentifier,
|
||||||
|
osmType,
|
||||||
|
osmId,
|
||||||
|
photos,
|
||||||
|
createdAt,
|
||||||
|
eventCount,
|
||||||
|
}) {
|
||||||
|
this.placeIdentifier = placeIdentifier;
|
||||||
|
this.osmType = osmType;
|
||||||
|
this.osmId = osmId;
|
||||||
|
this.photos = photos;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.eventCount = eventCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single kind 360 (Place Photo) event's `imeta` tag into a photo object.
|
||||||
|
* Reuses the same field shape as `parsePlacePhotos` in `utils/nostr.js` but operates
|
||||||
|
* on a single event (the NIP mandates exactly one `imeta` tag per event).
|
||||||
|
*
|
||||||
|
* @param {object} event A NIP-360 (kind 360) Nostr event
|
||||||
|
* @returns {object|null} A photo object, or null if no usable `imeta` was found
|
||||||
|
*/
|
||||||
|
function parsePhotoFromEvent(event) {
|
||||||
|
const tags = event.tags || [];
|
||||||
|
|
||||||
|
const eventTags = tags
|
||||||
|
.filter((t) => t[0] === 't')
|
||||||
|
.map((t) => t[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const imeta = tags.find((t) => t[0] === 'imeta');
|
||||||
|
if (!imeta) return null;
|
||||||
|
|
||||||
|
let url = null;
|
||||||
|
let thumbUrl = null;
|
||||||
|
let blurhash = null;
|
||||||
|
let isLandscape = false;
|
||||||
|
let aspectRatio = 16 / 9;
|
||||||
|
let altText = null;
|
||||||
|
|
||||||
|
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 [width, height] = tag.substring(4).split('x').map(Number);
|
||||||
|
if (width && height) {
|
||||||
|
aspectRatio = width / height;
|
||||||
|
if (width > height) isLandscape = true;
|
||||||
|
}
|
||||||
|
} else if (tag.startsWith('alt ')) {
|
||||||
|
const alt = tag.substring(4).trim();
|
||||||
|
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
const publishedAtRaw = tags.find((t) => t[0] === 'published_at')?.[1];
|
||||||
|
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||||
|
url,
|
||||||
|
thumbUrl,
|
||||||
|
blurhash,
|
||||||
|
isLandscape,
|
||||||
|
aspectRatio,
|
||||||
|
placeIdentifier,
|
||||||
|
tags: eventTags,
|
||||||
|
alt: altText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies kind 5 deletion events to a list of kind 360 events, returning the
|
||||||
|
* surviving kind 360 events.
|
||||||
|
*
|
||||||
|
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||||
|
* @returns {Array} Surviving kind 360 events
|
||||||
|
*/
|
||||||
|
function applyDeletions(events) {
|
||||||
|
const deletedIds = new Set();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.kind === 5) {
|
||||||
|
for (const tag of event.tags || []) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) deletedIds.add(tag[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events.filter(
|
||||||
|
(event) => event.kind === 360 && !deletedIds.has(event.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups kind 360 (Place Photo) events into contribution entries.
|
||||||
|
*
|
||||||
|
* Grouping rules:
|
||||||
|
* 1. Events are grouped by their OSM entity identifier (the `i` tag).
|
||||||
|
* 2. Within an entity, events are sub-grouped by time proximity: a new
|
||||||
|
* sub-group starts whenever the gap between two consecutive events
|
||||||
|
* exceeds `thresholdHours`.
|
||||||
|
* 3. Each sub-group becomes a single contribution entry with one or more
|
||||||
|
* photos, sorted by `created_at` descending (newest entry first).
|
||||||
|
*
|
||||||
|
* @param {Array} events Mixed kind 360 / kind 5 events from the user
|
||||||
|
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
|
||||||
|
* @returns {Array} Sorted contribution entries (newest first)
|
||||||
|
*/
|
||||||
|
export function groupPhotoContributions(events, thresholdHours = 3) {
|
||||||
|
if (!events || events.length === 0) return [];
|
||||||
|
|
||||||
|
const photoEvents = applyDeletions(events);
|
||||||
|
if (photoEvents.length === 0) return [];
|
||||||
|
|
||||||
|
// Group by OSM entity identifier
|
||||||
|
const byEntity = new Map();
|
||||||
|
for (const event of photoEvents) {
|
||||||
|
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
|
||||||
|
const entityId = entityTag?.[1];
|
||||||
|
if (!entityId) continue;
|
||||||
|
|
||||||
|
if (!byEntity.has(entityId)) byEntity.set(entityId, []);
|
||||||
|
byEntity.get(entityId).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const [entityId, entityEvents] of byEntity) {
|
||||||
|
// Sort newest-first within the entity
|
||||||
|
const sorted = [...entityEvents].sort(
|
||||||
|
(a, b) => b.created_at - a.created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// Walk newest -> oldest, starting a new sub-group when the gap to the
|
||||||
|
// previous event exceeds the threshold.
|
||||||
|
let currentGroup = [];
|
||||||
|
let prevTime = null;
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
|
||||||
|
entries.push(buildEntry(entityId, currentGroup));
|
||||||
|
currentGroup = [];
|
||||||
|
}
|
||||||
|
currentGroup.push(event);
|
||||||
|
prevTime = event.created_at;
|
||||||
|
}
|
||||||
|
if (currentGroup.length > 0) {
|
||||||
|
entries.push(buildEntry(entityId, currentGroup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries by their newest event's created_at, descending. Entries with
|
||||||
|
// no usable photos (e.g. malformed imeta) are filtered out.
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.photos.length > 0)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single contribution entry object from a group of events for the
|
||||||
|
* same OSM entity.
|
||||||
|
*
|
||||||
|
* @param {string} placeIdentifier e.g. "osm:node:123456"
|
||||||
|
* @param {Array} events Kind 360 events in this sub-group
|
||||||
|
* @returns {object} Contribution entry
|
||||||
|
*/
|
||||||
|
function buildEntry(placeIdentifier, events) {
|
||||||
|
const photos = events
|
||||||
|
.map(parsePhotoFromEvent)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
|
const createdAt = events.reduce(
|
||||||
|
(max, e) => (e.created_at > max ? e.created_at : max),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, osmType, osmId] = placeIdentifier.split(':');
|
||||||
|
|
||||||
|
return new ContributionEntry({
|
||||||
|
placeIdentifier,
|
||||||
|
osmType,
|
||||||
|
osmId,
|
||||||
|
photos,
|
||||||
|
createdAt,
|
||||||
|
eventCount: events.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,3 +7,28 @@ export function humanizeOsmTag(text) {
|
|||||||
w.replace(/^\w/, (c) => c.toUpperCase())
|
w.replace(/^\w/, (c) => c.toUpperCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capitalize(text) {
|
||||||
|
if (typeof text !== 'string' || !text) return '';
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeDate(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffMin < 1) return 'just now';
|
||||||
|
if (diffMin < 60) return `${diffMin} min ago`;
|
||||||
|
if (diffHr < 24) return `${diffHr} hr ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function getGeohashPrefixesInBbox(bbox) {
|
|||||||
|
|
||||||
// Safety check to avoid infinite loops or massive arrays if bbox is weird
|
// Safety check to avoid infinite loops or massive arrays if bbox is weird
|
||||||
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
|
if (Math.abs(maxLat - minLat) > 20 || Math.abs(maxLon - minLon) > 20) {
|
||||||
console.warn(
|
console.debug(
|
||||||
'BBox too large for 4-char geohash scanning, aborting fine scan.'
|
'BBox too large for 4-char geohash scanning, aborting fine scan.'
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
+40
-3
@@ -1,12 +1,17 @@
|
|||||||
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
|
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
|
||||||
|
/*
|
||||||
|
* Feather icons
|
||||||
|
*/
|
||||||
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 featherCamera 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 chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||||
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
|
||||||
|
import alertTriangle from 'feather-icons/dist/icons/alert-triangle.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.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 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';
|
||||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||||
@@ -21,6 +26,8 @@ import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
|||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
|
import moreHorizontal from 'feather-icons/dist/icons/more-horizontal.svg?raw';
|
||||||
|
import moreVertical from 'feather-icons/dist/icons/more-vertical.svg?raw';
|
||||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||||
@@ -36,6 +43,9 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
|
|||||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.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';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pinhead icons
|
||||||
|
*/
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||||
@@ -44,11 +54,17 @@ import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield
|
|||||||
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
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 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 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 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 boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||||
|
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
|
||||||
|
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.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 cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||||
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
|
import classicalBuildingWithClock from '@waysidemapping/pinhead/dist/icons/classical_building_with_clock.svg?raw';
|
||||||
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
||||||
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
||||||
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
||||||
@@ -75,6 +91,8 @@ import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
|||||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.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 greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.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 infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||||
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||||
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||||
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||||
@@ -84,7 +102,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
|||||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||||
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||||
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||||
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||||
@@ -100,8 +118,9 @@ import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_
|
|||||||
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||||
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||||
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
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 scissorsOpen from '@waysidemapping/pinhead/dist/icons/open_scissors.svg?raw';
|
||||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.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 shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||||
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||||
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
|
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
|
||||||
@@ -111,8 +130,12 @@ import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
|||||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||||
|
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Custom/local icons
|
||||||
|
*/
|
||||||
import loadingRing from '../icons/270-ring.svg?raw';
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||||
@@ -131,16 +154,21 @@ const ICONS = {
|
|||||||
bookmark,
|
bookmark,
|
||||||
'boxing-glove-up': boxingGloveUp,
|
'boxing-glove-up': boxingGloveUp,
|
||||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||||
|
bridge,
|
||||||
bus,
|
bus,
|
||||||
camera,
|
camera,
|
||||||
|
'feather-camera': featherCamera,
|
||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
'chevron-left': chevronLeft,
|
'chevron-left': chevronLeft,
|
||||||
'chevron-right': chevronRight,
|
'chevron-right': chevronRight,
|
||||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
|
'city-gate': cityGate,
|
||||||
climbing_wall: climbingWall,
|
climbing_wall: climbingWall,
|
||||||
check,
|
check,
|
||||||
'alert-circle': alertCircle,
|
'alert-circle': alertCircle,
|
||||||
|
'alert-triangle': alertTriangle,
|
||||||
'classical-building': classicalBuilding,
|
'classical-building': classicalBuilding,
|
||||||
|
'classical-building-with-clock': classicalBuildingWithClock,
|
||||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
'commercial-building': commercialBuilding,
|
'commercial-building': commercialBuilding,
|
||||||
@@ -153,6 +181,7 @@ const ICONS = {
|
|||||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||||
croissant,
|
croissant,
|
||||||
'cup-and-saucer': cupAndSaucer,
|
'cup-and-saucer': cupAndSaucer,
|
||||||
|
database,
|
||||||
donut,
|
donut,
|
||||||
edit,
|
edit,
|
||||||
eyeglasses,
|
eyeglasses,
|
||||||
@@ -174,7 +203,9 @@ const ICONS = {
|
|||||||
heart,
|
heart,
|
||||||
home,
|
home,
|
||||||
'ice-cream-on-cone': iceCreamOnCone,
|
'ice-cream-on-cone': iceCreamOnCone,
|
||||||
|
'industrial-building': industrialBuilding,
|
||||||
info,
|
info,
|
||||||
|
'info-i': infoI,
|
||||||
instagram,
|
instagram,
|
||||||
jewel,
|
jewel,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
@@ -188,6 +219,8 @@ const ICONS = {
|
|||||||
menu,
|
menu,
|
||||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||||
'molar-tooth': molarTooth,
|
'molar-tooth': molarTooth,
|
||||||
|
'more-horizontal': moreHorizontal,
|
||||||
|
'more-vertical': moreVertical,
|
||||||
navigation,
|
navigation,
|
||||||
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||||
nostrich,
|
nostrich,
|
||||||
@@ -214,6 +247,7 @@ const ICONS = {
|
|||||||
'sailing-ship-in-water': sailingShipInWater,
|
'sailing-ship-in-water': sailingShipInWater,
|
||||||
'scissors-open': scissorsOpen,
|
'scissors-open': scissorsOpen,
|
||||||
'shipwreck-in-water': shipwreckInWater,
|
'shipwreck-in-water': shipwreckInWater,
|
||||||
|
'steam-train-on-railway-track': steamTrainOnRailwayTrack,
|
||||||
'shopping-bag': shoppingBag,
|
'shopping-bag': shoppingBag,
|
||||||
search,
|
search,
|
||||||
server,
|
server,
|
||||||
@@ -233,8 +267,11 @@ const ICONS = {
|
|||||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
whatsapp,
|
whatsapp,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
winding_way_wide: windingWayWide,
|
||||||
parking_p: parkingP,
|
parking_p: parkingP,
|
||||||
car,
|
car,
|
||||||
|
'car-and-wrench': carAndWrench,
|
||||||
|
'castle-keep': castleKeep,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
'loading-ring': loadingRing,
|
'loading-ring': loadingRing,
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ export function normalizeRelayUrl(url) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function uniqNormalizedRelays(relays = []) {
|
||||||
|
return Array.from(new Set(relays.map(normalizeRelayUrl).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRequiredRelays(requiredRelays = [], customRelays = []) {
|
||||||
|
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||||
|
const merged = [...requiredRelays.filter(Boolean)];
|
||||||
|
|
||||||
|
for (const relay of uniqNormalizedRelays(customRelays)) {
|
||||||
|
if (!requiredSet.has(relay)) {
|
||||||
|
merged.push(relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||||
|
const requiredSet = new Set(requiredRelays.filter(Boolean));
|
||||||
|
return uniqNormalizedRelays(customRelays).filter((relay) => {
|
||||||
|
return !requiredSet.has(relay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
* 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.
|
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||||
@@ -30,6 +54,11 @@ export function parsePlacePhotos(events) {
|
|||||||
const allPhotos = [];
|
const allPhotos = [];
|
||||||
|
|
||||||
for (const event of sortedEvents) {
|
for (const event of sortedEvents) {
|
||||||
|
const eventTagValues = event.tags
|
||||||
|
.filter((t) => t[0] === 't')
|
||||||
|
.map((t) => t[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
// Find all imeta tags
|
// Find all imeta tags
|
||||||
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||||
for (const imeta of imetas) {
|
for (const imeta of imetas) {
|
||||||
@@ -38,6 +67,12 @@ export function parsePlacePhotos(events) {
|
|||||||
let blurhash = null;
|
let blurhash = null;
|
||||||
let isLandscape = false;
|
let isLandscape = false;
|
||||||
let aspectRatio = 16 / 9; // default
|
let aspectRatio = 16 / 9; // default
|
||||||
|
let altText = null;
|
||||||
|
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
const publishedAtRaw = event.tags.find(
|
||||||
|
(t) => t[0] === 'published_at'
|
||||||
|
)?.[1];
|
||||||
|
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||||
|
|
||||||
for (const tag of imeta.slice(1)) {
|
for (const tag of imeta.slice(1)) {
|
||||||
if (tag.startsWith('url ')) {
|
if (tag.startsWith('url ')) {
|
||||||
@@ -55,6 +90,10 @@ export function parsePlacePhotos(events) {
|
|||||||
isLandscape = true;
|
isLandscape = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (tag.startsWith('alt ')) {
|
||||||
|
const alt = tag.substring(4).trim();
|
||||||
|
// Strip the legacy placeholder we used to write on every event
|
||||||
|
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +102,15 @@ export function parsePlacePhotos(events) {
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||||
url,
|
url,
|
||||||
thumbUrl,
|
thumbUrl,
|
||||||
blurhash,
|
blurhash,
|
||||||
isLandscape,
|
isLandscape,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
placeIdentifier,
|
||||||
|
tags: eventTagValues,
|
||||||
|
alt: altText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const POI_ICON_RULES = [
|
|||||||
|
|
||||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||||
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||||
|
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||||
|
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||||
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||||
@@ -72,6 +74,7 @@ export const POI_ICON_RULES = [
|
|||||||
tags: { shop: 'beauty' },
|
tags: { shop: 'beauty' },
|
||||||
icon: 'fancy-mirror-with-reflection-and-stars',
|
icon: 'fancy-mirror-with-reflection-and-stars',
|
||||||
},
|
},
|
||||||
|
{ tags: { shop: 'car_repair' }, icon: 'car-and-wrench' },
|
||||||
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
{ tags: { office: true }, icon: 'commercial-building' },
|
{ tags: { office: true }, icon: 'commercial-building' },
|
||||||
@@ -103,14 +106,20 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
||||||
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
||||||
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'information' }, icon: 'info-i' },
|
||||||
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
||||||
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
||||||
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
|
||||||
// Historic
|
// Historic
|
||||||
|
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||||
|
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||||
|
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
|
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
|
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||||
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||||
@@ -119,6 +128,12 @@ export const POI_ICON_RULES = [
|
|||||||
tags: { historic: 'monument' },
|
tags: { historic: 'monument' },
|
||||||
icon: 'classical-building-with-dome-and-flag',
|
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: 'ship' }, icon: 'sailing-ship-in-water' },
|
||||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||||
|
|||||||
+10
-1
@@ -56,15 +56,24 @@ const PLACE_TYPE_KEYS = [
|
|||||||
export function getPlaceType(tags) {
|
export function getPlaceType(tags) {
|
||||||
if (!tags) return null;
|
if (!tags) return null;
|
||||||
|
|
||||||
|
let fallbackKey = null;
|
||||||
|
|
||||||
for (const key of PLACE_TYPE_KEYS) {
|
for (const key of PLACE_TYPE_KEYS) {
|
||||||
const value = tags[key];
|
const value = tags[key];
|
||||||
if (value) {
|
if (value) {
|
||||||
if (value === 'yes') {
|
if (value === 'yes') {
|
||||||
return humanizeOsmTag(key);
|
if (!fallbackKey) {
|
||||||
|
fallbackKey = key;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return humanizeOsmTag(value);
|
return humanizeOsmTag(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackKey) {
|
||||||
|
return humanizeOsmTag(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { POI_CATEGORIES } from './poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||||
|
|
||||||
|
export const CATEGORY_TAGS = {
|
||||||
|
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
groceries: ['front', 'food'],
|
||||||
|
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||||
|
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSuggestedPhotoTags(place) {
|
||||||
|
const osmTags = place?.osmTags || place?.tags || {};
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
const suggested = [];
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
const tags = CATEGORY_TAGS[categoryId];
|
||||||
|
if (!Array.isArray(tags)) continue;
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!suggested.includes(tag)) {
|
||||||
|
suggested.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggested.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggested;
|
||||||
|
}
|
||||||
@@ -41,9 +41,9 @@ export const POI_CATEGORIES = [
|
|||||||
{
|
{
|
||||||
id: 'things-to-do',
|
id: 'things-to-do',
|
||||||
label: 'Things to do',
|
label: 'Things to do',
|
||||||
icon: 'camera',
|
icon: 'feather-camera',
|
||||||
filter: [
|
filter: [
|
||||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||||
'["historic"]',
|
'["historic"]',
|
||||||
@@ -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|chalet)$"]'],
|
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||||
types: ['node', 'way', 'relation'],
|
types: ['node', 'way', 'relation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
export function getMatchingPoiCategories(osmTags, categories) {
|
||||||
|
if (!Array.isArray(categories) || !osmTags) return [];
|
||||||
|
|
||||||
|
return categories.filter((category) => {
|
||||||
|
if (!Array.isArray(category.filter)) return false;
|
||||||
|
return category.filter.some((filterStr) =>
|
||||||
|
matchesFilter(osmTags, filterStr)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingPoiCategoryIds(osmTags, categories) {
|
||||||
|
return getMatchingPoiCategories(osmTags, categories).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(osmTags, filterStr) {
|
||||||
|
const clauses = parseOverpassClauses(filterStr);
|
||||||
|
if (clauses.length === 0) return false;
|
||||||
|
return clauses.every((clause) => matchesClause(osmTags, clause));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOverpassClauses(filterStr) {
|
||||||
|
if (!filterStr) return [];
|
||||||
|
const matches = filterStr.match(/\[[^\]]+\]/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map((raw) => parseClause(raw.slice(1, -1).trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClause(content) {
|
||||||
|
const presenceMatch = content.match(/^"([^"]+)"$/);
|
||||||
|
if (presenceMatch) {
|
||||||
|
return { type: 'presence', key: presenceMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/);
|
||||||
|
if (equalsMatch) {
|
||||||
|
return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/);
|
||||||
|
if (regexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'regex',
|
||||||
|
key: regexMatch[1],
|
||||||
|
pattern: regexMatch[2],
|
||||||
|
regex: new RegExp(regexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/);
|
||||||
|
if (notRegexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'not-regex',
|
||||||
|
key: notRegexMatch[1],
|
||||||
|
pattern: notRegexMatch[2],
|
||||||
|
regex: new RegExp(notRegexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesClause(osmTags, clause) {
|
||||||
|
const tagValues = getTagValues(osmTags, clause.key);
|
||||||
|
|
||||||
|
switch (clause.type) {
|
||||||
|
case 'presence':
|
||||||
|
return tagValues.length > 0;
|
||||||
|
case 'equals':
|
||||||
|
return tagValues.some((value) => value === clause.value);
|
||||||
|
case 'regex':
|
||||||
|
return tagValues.some((value) => clause.regex.test(value));
|
||||||
|
case 'not-regex':
|
||||||
|
return (
|
||||||
|
tagValues.length === 0 ||
|
||||||
|
!tagValues.some((value) => clause.regex.test(value))
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagValues(osmTags, key) {
|
||||||
|
if (!osmTags || !key) return [];
|
||||||
|
const rawValue = osmTags[key];
|
||||||
|
if (rawValue === undefined || rawValue === null) return [];
|
||||||
|
|
||||||
|
return String(rawValue)
|
||||||
|
.split(';')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
@@ -37,8 +37,6 @@ export default {
|
|||||||
'@babel/plugin-transform-runtime',
|
'@babel/plugin-transform-runtime',
|
||||||
{
|
{
|
||||||
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
||||||
useESModules: true,
|
|
||||||
regenerator: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...macros.babelMacros,
|
...macros.babelMacros,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
|
|||||||
|
|
||||||
## Content
|
## 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
|
## Tags
|
||||||
|
|
||||||
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
|
|||||||
|
|
||||||
#### 3. `imeta` — Inline Media Metadata
|
#### 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.
|
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
|
```json
|
||||||
[
|
[
|
||||||
"imeta",
|
"imeta",
|
||||||
"url https://example.com/photo.jpg",
|
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||||
"m image/jpeg",
|
"m image/jpeg",
|
||||||
"dim 3024x4032",
|
"dim 1440x1920",
|
||||||
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
"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$"
|
"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",
|
"imeta",
|
||||||
"url https://example.com/ramen.jpg",
|
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||||
"m image/jpeg",
|
"m image/jpeg",
|
||||||
"dim 1080x1080",
|
"dim 1920x1920",
|
||||||
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
"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"
|
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
|
### Kind 360
|
||||||
|
|
||||||
|
Easy to remember as a 360-degree view of places.
|
||||||
|
|
||||||
### Why not use NIP-68 (Picture-first feeds)?
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
|
|||||||
|
|
||||||
## Rationale
|
## 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
|
### No Place Field in Content
|
||||||
|
|
||||||
Avoids duplication and inconsistency with tags.
|
Avoids duplication and inconsistency with tags.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
|||||||
import qunit from 'eslint-plugin-qunit';
|
import qunit from 'eslint-plugin-qunit';
|
||||||
import n from 'eslint-plugin-n';
|
import n from 'eslint-plugin-n';
|
||||||
|
|
||||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
import babelParser from '@babel/eslint-parser';
|
||||||
|
|
||||||
const esmParserOptions = {
|
const esmParserOptions = {
|
||||||
ecmaFeatures: { modules: true },
|
ecmaFeatures: { modules: true },
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<link rel="stylesheet" href="/app/styles/app.css">
|
<link rel="stylesheet" href="/app/styles/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="modal-portal"></div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import Application from './app/app';
|
import Application from './app/app';
|
||||||
import environment from './app/config/environment';
|
import environment from './app/config/environment';
|
||||||
|
|||||||
+59
-56
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.20.0",
|
"version": "1.29.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -39,61 +39,61 @@
|
|||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/eslint-parser": "^7.28.5",
|
"@babel/eslint-parser": "^8.0.1",
|
||||||
"@babel/plugin-transform-runtime": "^7.28.5",
|
"@babel/plugin-transform-runtime": "^8.0.1",
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^8.0.0",
|
||||||
"@ember/test-helpers": "^5.4.1",
|
"@ember/test-helpers": "^5.4.3",
|
||||||
"@ember/test-waiters": "^4.1.1",
|
"@ember/test-waiters": "^4.1.2",
|
||||||
"@embroider/core": "^4.4.2",
|
"@embroider/core": "^4.6.3",
|
||||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||||
"@embroider/macros": "^1.19.6",
|
"@embroider/macros": "^1.20.6",
|
||||||
"@embroider/router": "^3.0.6",
|
"@embroider/router": "^3.0.6",
|
||||||
"@embroider/vite": "^1.5.0",
|
"@embroider/vite": "^1.7.9",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@glimmer/component": "^2.0.0",
|
"@glimmer/component": "^2.1.1",
|
||||||
"@remotestorage/module-places": "~1.2.1",
|
"@remotestorage/module-places": "~1.3.0",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^7.1.0",
|
||||||
"@warp-drive/core": "~5.8.0",
|
"@warp-drive/core": "~5.8.2",
|
||||||
"@warp-drive/ember": "~5.8.0",
|
"@warp-drive/ember": "~5.8.2",
|
||||||
"@warp-drive/json-api": "~5.8.0",
|
"@warp-drive/json-api": "~5.8.2",
|
||||||
"@warp-drive/legacy": "~5.8.0",
|
"@warp-drive/legacy": "~5.8.2",
|
||||||
"@warp-drive/utilities": "~5.8.0",
|
"@warp-drive/utilities": "~5.8.2",
|
||||||
"babel-plugin-ember-template-compilation": "^3.0.1",
|
"babel-plugin-ember-template-compilation": "^4.0.0",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^10.0.4",
|
||||||
"decorator-transforms": "^2.3.1",
|
"decorator-transforms": "^2.4.0",
|
||||||
"ember-cli": "^6.10.0",
|
"ember-cli": "^7.1.0",
|
||||||
"ember-cli-deprecation-workflow": "^4.0.0",
|
"ember-cli-deprecation-workflow": "^4.0.1",
|
||||||
"ember-modifier": "^4.2.2",
|
"ember-modifier": "^4.3.0",
|
||||||
"ember-page-title": "^9.0.3",
|
"ember-page-title": "^9.0.3",
|
||||||
"ember-qunit": "^9.0.4",
|
"ember-qunit": "^9.1.0",
|
||||||
"ember-resolver": "^13.1.1",
|
"ember-resolver": "^13.2.0",
|
||||||
"ember-source": "~6.11.0-alpha.6",
|
"ember-source": "~7.2.0",
|
||||||
"ember-template-lint": "^7.9.3",
|
"ember-template-lint": "^7.9.3",
|
||||||
"ember-truth-helpers": "^5.0.0",
|
"ember-truth-helpers": "^5.0.0",
|
||||||
"ember-welcome-page": "^8.0.4",
|
"ember-welcome-page": "^8.0.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.8.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-ember": "^12.7.5",
|
"eslint-plugin-ember": "^13.5.0",
|
||||||
"eslint-plugin-n": "^17.23.1",
|
"eslint-plugin-n": "^18.3.0",
|
||||||
"eslint-plugin-qunit": "^8.2.5",
|
"eslint-plugin-qunit": "^8.2.6",
|
||||||
"eslint-plugin-warp-drive": "^5.8.0",
|
"eslint-plugin-warp-drive": "^5.8.2",
|
||||||
"feather-icons": "^4.29.2",
|
"feather-icons": "^4.29.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.11.0",
|
||||||
"latlon-geohash": "^2.0.0",
|
"latlon-geohash": "^2.0.0",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.10.0",
|
||||||
"ol-mapbox-style": "^13.2.0",
|
"ol-mapbox-style": "^13.4.2",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
"prettier-plugin-ember-template-tag": "^2.1.7",
|
||||||
"qunit": "^2.25.0",
|
"qunit": "^2.26.0",
|
||||||
"qunit-dom": "^3.5.0",
|
"qunit-dom": "^3.6.0",
|
||||||
"remotestorage-widget": "^1.8.1",
|
"remotestorage-widget": "^1.8.1",
|
||||||
"remotestoragejs": "2.0.0-beta.8",
|
"remotestoragejs": "2.0.0-beta.10",
|
||||||
"sinon": "^21.0.1",
|
"sinon": "^22.1.0",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^17.14.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^40.0.0",
|
||||||
"testem": "^3.17.0",
|
"testem": "^3.20.1",
|
||||||
"vite": "^7.3.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -102,18 +102,21 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@waysidemapping/pinhead": "^15.20.0",
|
"@waysidemapping/pinhead": "^15.25.0",
|
||||||
"applesauce-core": "^5.2.0",
|
"applesauce-common": "^6.2.0",
|
||||||
"applesauce-factory": "^4.0.0",
|
"applesauce-core": "^6.2.0",
|
||||||
"applesauce-relay": "^5.2.0",
|
"applesauce-loaders": "^6.2.0",
|
||||||
"applesauce-signers": "^5.2.0",
|
"applesauce-relay": "^6.2.1",
|
||||||
|
"applesauce-signers": "^6.2.2",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.1.0",
|
||||||
"nostr-idb": "^5.0.0",
|
"localforage": "^1.10.0",
|
||||||
"oauth2-pkce": "^2.1.3",
|
"nostr-idb": "^5.1.0",
|
||||||
|
"oauth2-pkce": "^3.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/async-arrow-task-transform.js b/async-arrow-task-transform.js
|
||||||
|
index 4eb856a2a7753ef583e10e07f2b602c0c1c0c21d..6a808d035834567dcfc491ecaa7f59e77d344887 100644
|
||||||
|
--- a/async-arrow-task-transform.js
|
||||||
|
+++ b/async-arrow-task-transform.js
|
||||||
|
@@ -331,7 +331,7 @@ function cleanupUnusedTaskFactoryImports(programPath, usedTaskFactories) {
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = declare((api) => {
|
||||||
|
- api.assertVersion(7);
|
||||||
|
+ // api.assertVersion(7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'transform-ember-concurrency-async-function-tasks',
|
||||||
Generated
+4358
-3182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
patchedDependencies:
|
||||||
|
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
|
||||||
@@ -1 +0,0 @@
|
|||||||
!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})}}}();
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
var q = [
|
||||||
|
"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",
|
||||||
|
"#",
|
||||||
|
"$",
|
||||||
|
"%",
|
||||||
|
"*",
|
||||||
|
"+",
|
||||||
|
",",
|
||||||
|
"-",
|
||||||
|
".",
|
||||||
|
":",
|
||||||
|
";",
|
||||||
|
"=",
|
||||||
|
"?",
|
||||||
|
"@",
|
||||||
|
"[",
|
||||||
|
"]",
|
||||||
|
"^",
|
||||||
|
"_",
|
||||||
|
"{",
|
||||||
|
"|",
|
||||||
|
"}",
|
||||||
|
"~"
|
||||||
|
], p = (t, e) => {
|
||||||
|
var a = "";
|
||||||
|
for (let o = 1; o <= e; o++) {
|
||||||
|
let h = Math.floor(t) / Math.pow(83, e - o) % 83;
|
||||||
|
a += q[Math.floor(h)];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}, f = (t) => {
|
||||||
|
let e = t / 255;
|
||||||
|
return e <= .04045 ? e / 12.92 : Math.pow((e + .055) / 1.055, 2.4);
|
||||||
|
}, h = (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);
|
||||||
|
}, M = (t, e) => ((t) => t < 0 ? -1 : 1)(t) * Math.pow(Math.abs(t), e), d = class extends Error {
|
||||||
|
constructor(t) {
|
||||||
|
super(t), this.name = "ValidationError", this.message = t;
|
||||||
|
}
|
||||||
|
}, D = (t, e, a, o) => {
|
||||||
|
let h = 0, r = 0, n = 0, i = 4 * e;
|
||||||
|
for (let l = 0; l < e; l++) {
|
||||||
|
let e = 4 * l;
|
||||||
|
for (let s = 0; s < a; s++) {
|
||||||
|
let a = e + s * i, g = o(l, s);
|
||||||
|
h += g * f(t[a]), r += g * f(t[a + 1]), n += g * f(t[a + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s = 1 / (e * a);
|
||||||
|
return [
|
||||||
|
h * s,
|
||||||
|
r * s,
|
||||||
|
n * s
|
||||||
|
];
|
||||||
|
}, S = (t, e, a, o, r) => {
|
||||||
|
if (o < 1 || o > 9 || r < 1 || r > 9) throw new d("BlurHash must have between 1 and 9 components");
|
||||||
|
if (e * a * 4 !== t.length) throw new d("Width and height must match the pixels array");
|
||||||
|
let n = [];
|
||||||
|
for (let h = 0; h < r; h++) for (let r = 0; r < o; r++) {
|
||||||
|
let o = 0 == r && 0 == h ? 1 : 2, i = D(t, e, a, (t, n) => o * Math.cos(Math.PI * r * t / e) * Math.cos(Math.PI * h * n / a));
|
||||||
|
n.push(i);
|
||||||
|
}
|
||||||
|
let i, s = n[0], l = n.slice(1), g = "";
|
||||||
|
if (g += p(o - 1 + 9 * (r - 1), 1), l.length > 0) {
|
||||||
|
let t = Math.max(...l.map((t) => Math.max(...t))), e = Math.floor(Math.max(0, Math.min(82, Math.floor(166 * t - .5))));
|
||||||
|
i = (e + 1) / 166, g += p(e, 1);
|
||||||
|
} else i = 1, g += p(0, 1);
|
||||||
|
return g += p(((t) => (h(t[0]) << 16) + (h(t[1]) << 8) + h(t[2]))(s), 4), l.forEach((t) => {
|
||||||
|
g += p(((t, e) => 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[0] / e, .5) + 9.5)))) * 19 + 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[1] / e, .5) + 9.5)))) + Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[2] / e, .5) + 9.5)))))(t, i), 2);
|
||||||
|
}), g;
|
||||||
|
};
|
||||||
|
self.onmessage = async (t) => {
|
||||||
|
if ("PROCESS_IMAGE" !== t.data?.type) return;
|
||||||
|
const { id: e, file: a, targetWidth: o, targetHeight: h, quality: r, computeBlurhash: n } = t.data;
|
||||||
|
try {
|
||||||
|
let t, l;
|
||||||
|
try {
|
||||||
|
const e = await createImageBitmap(a, {
|
||||||
|
resizeWidth: o,
|
||||||
|
resizeHeight: h,
|
||||||
|
resizeQuality: "high"
|
||||||
|
});
|
||||||
|
if (t = new OffscreenCanvas(o, h), l = t.getContext("2d"), !l) throw new Error("Failed to get 2d context from OffscreenCanvas");
|
||||||
|
l.drawImage(e, 0, 0, o, h), e.close();
|
||||||
|
} catch (i) {
|
||||||
|
console.warn("Hardware resize failed, falling back to stepped software scaling:", i);
|
||||||
|
const e = await a.arrayBuffer(), r = new Blob([e], { type: a.type }), n = await createImageBitmap(r);
|
||||||
|
let s = n.width, g = n.height, f = new OffscreenCanvas(s, g), c = f.getContext("2d");
|
||||||
|
for (c.imageSmoothingEnabled = !0, c.imageSmoothingQuality = "high", c.drawImage(n, 0, 0); .5 * f.width > o && .5 * f.height > h;) {
|
||||||
|
const t = new OffscreenCanvas(Math.floor(.5 * f.width), Math.floor(.5 * f.height)), e = t.getContext("2d");
|
||||||
|
e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high", e.drawImage(f, 0, 0, t.width, t.height), f = t;
|
||||||
|
}
|
||||||
|
t = new OffscreenCanvas(o, h), l = t.getContext("2d"), l.imageSmoothingEnabled = !0, l.imageSmoothingQuality = "high", l.drawImage(f, 0, 0, o, h), n.close();
|
||||||
|
}
|
||||||
|
let g = null;
|
||||||
|
if (n) try {
|
||||||
|
const t = l.getImageData(0, 0, o, h);
|
||||||
|
g = S(t.data, o, h, 4, 3);
|
||||||
|
} catch (s) {
|
||||||
|
console.warn("Could not generate blurhash (possible canvas fingerprinting protection):", s);
|
||||||
|
}
|
||||||
|
const f = await t.convertToBlob({
|
||||||
|
type: "image/jpeg",
|
||||||
|
quality: r
|
||||||
|
}), c = `${o}x${h}`;
|
||||||
|
self.postMessage({
|
||||||
|
id: e,
|
||||||
|
success: !0,
|
||||||
|
blob: f,
|
||||||
|
dim: c,
|
||||||
|
blurhash: g
|
||||||
|
});
|
||||||
|
} catch (l) {
|
||||||
|
self.postMessage({
|
||||||
|
id: e,
|
||||||
|
success: !1,
|
||||||
|
error: l.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user