Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a19e30b8
|
||
|
|
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
|
||
|
|
c9465c8fa8
|
||
|
|
6c5c1fea27
|
||
|
|
fe41369754
|
||
|
|
1498c5a713
|
||
|
|
b6e2964f8e
|
||
|
|
d1d179bb93
|
||
|
|
b83a16bf13
|
||
|
|
c853418fbb
|
||
|
|
4fed8c05c5
|
||
|
|
670128cbda
|
||
|
|
d8fa30c74b
|
||
|
|
0f8d7046ac
|
||
|
|
8ca7481a79
|
||
|
|
cd25c55bd7
|
||
|
|
32c4f7da57
|
||
|
|
71939a30c3
|
||
|
|
7285ace882
|
||
|
|
94ba33ecc1
|
||
|
|
85a8699b78
|
||
|
|
99cfd96ca1
|
||
|
|
8d40b3bb35
|
||
|
|
c5316bf336
|
||
|
|
a384e83dd0
|
||
|
|
b23d54d74f
|
||
|
|
5bd4dba907
|
||
|
|
54ba99673f
|
||
|
|
54445f249b
|
||
|
|
9828ad2714
|
||
|
|
a89ba904c8
|
||
|
|
4c540bc713
|
||
|
|
bb2411972f
|
||
|
|
5cd384cf3a
|
||
|
|
ec31d1a59b
|
||
|
|
4f55f26851
|
||
|
|
b7cce6eb7e
|
||
|
|
79777fb51a
|
||
|
|
1ed66ca744
|
||
|
|
a2a61b0fec
|
||
|
|
d9ba73559e
|
||
|
|
f1ebafc1f0
|
||
|
|
10501b64bd
|
||
|
|
7607f27013
|
||
|
|
8cc579e271
|
||
|
|
3a56464926
|
||
|
|
1dc0c4119b
|
||
|
|
c57a665655
|
||
|
|
6cfe2b40b9
|
||
|
|
99d8ca9174
|
||
|
|
629a308b79
|
||
|
|
798ed0c8dd
|
||
|
|
03583e5a52
|
||
|
|
b9f64f30e1
|
||
|
|
4bd5c4bf2a
|
||
|
|
f875fc1877
|
||
|
|
2268a607d5
|
||
|
|
f01b5f8faa
|
||
|
|
9075089221
|
||
|
|
5ad702e6e6
|
||
|
|
bae01a3c9b
|
||
|
|
0efc8994e9
|
||
|
|
5c71523d90
|
||
|
|
bea1b97fb7
|
||
|
|
6ba1cf31cf
|
||
|
|
9cdd021cda
|
||
|
|
ef53870b35
|
||
|
|
918a794784
|
||
|
|
344a3067fa
|
||
|
|
ad3e6ea402
|
||
|
|
9e2545da7b
|
||
|
|
480c97fb9d
|
||
|
|
179cf49370
|
||
|
|
aea0388267
|
||
|
|
e4d02cda26
|
||
|
|
27ebbaca60
|
||
|
|
cbdd056dcb
|
||
|
|
2423b67f94
|
||
|
|
2a3ad26eb9
|
No files matched your search
@@ -0,0 +1,359 @@
|
|||||||
|
---
|
||||||
|
name: nak
|
||||||
|
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
|
||||||
|
license: CC-BY-SA-4.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# nak - Nostr Army Knife
|
||||||
|
|
||||||
|
Work with the Nostr protocol using the `nak` CLI tool.
|
||||||
|
|
||||||
|
GitHub: https://github.com/fiatjaf/nak
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install (or upgrade) nak:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 1: Query & Discovery
|
||||||
|
*"Finding events and data on Nostr"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Fetch by identifier: "Get event/profile without knowing relays"
|
||||||
|
- `nak fetch nevent1...` (uses embedded relay hints)
|
||||||
|
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
|
||||||
|
- Query by event ID: "I want THIS specific event from a relay"
|
||||||
|
- `nak req -i <event_id> <relay>`
|
||||||
|
- Query by author: "Show me everything from this person"
|
||||||
|
- `nak req -a <pubkey> <relay>`
|
||||||
|
- Query by kind: "Show me all profiles/notes/videos"
|
||||||
|
- `nak req -k <kind> <relay>`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Fetch addressable events: "Get event by naddr/nprofile"
|
||||||
|
- `nak fetch naddr1...` (kind, author, identifier encoded)
|
||||||
|
- `nak fetch -r relay.primal.net naddr1...` (override relays)
|
||||||
|
- Filter by multiple criteria: "Find posts by author X of kind Y"
|
||||||
|
- `nak req -k 1 -a <pubkey> <relay>`
|
||||||
|
- Tag-based queries: "Find events tagged with bitcoin"
|
||||||
|
- `nak req --tag t=bitcoin <relay>`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Search with ranking: "Find trending/top content"
|
||||||
|
- `nak req --search "sort:hot" <relay>`
|
||||||
|
- `nak req --search "popular:24h" <relay>`
|
||||||
|
- Live monitoring: "Watch events in real-time"
|
||||||
|
- `nak req --stream <relay>`
|
||||||
|
- Cross-protocol queries: "Find bridged Bluesky content"
|
||||||
|
- `nak req --tag "proxy=at://..." <relay>`
|
||||||
|
|
||||||
|
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 2: Broadcast & Migration
|
||||||
|
*"Moving/copying events between relays"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Publish a single event: "Put this on relay X"
|
||||||
|
- `cat event.json | nak event <relay>`
|
||||||
|
- Query and republish: "Copy event from relay A to relay B"
|
||||||
|
- `nak req -i <id> relay1 | nak event relay2`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Batch migration: "Copy all events of kind X"
|
||||||
|
- `nak req -k 30717 source_relay | nak event dest_relay`
|
||||||
|
- Paginated backup: "Download everything from a relay"
|
||||||
|
- `nak req --paginate source_relay | nak event backup_relay`
|
||||||
|
- Multi-relay broadcast: "Publish to multiple relays at once"
|
||||||
|
- `cat event.json | nak event relay1 relay2 relay3`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Selective migration: "Copy follow list members' data"
|
||||||
|
- Loop through follow list, query each author, republish to new relay
|
||||||
|
- Filter and migrate: "Copy only tagged/searched content"
|
||||||
|
- `nak req --tag client=X relay1 | nak event relay2`
|
||||||
|
- Cross-relay synchronization: "Keep two relays in sync"
|
||||||
|
- `nak sync source_relay dest_relay`
|
||||||
|
|
||||||
|
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 3: Identity & Encoding
|
||||||
|
*"Working with Nostr identifiers and keys"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Decode identifiers: "What's inside this npub/nevent?"
|
||||||
|
- `nak decode npub1...`
|
||||||
|
- `nak decode user@domain.com`
|
||||||
|
- Encode identifiers: "Turn this pubkey into npub"
|
||||||
|
- `nak encode npub <hex_pubkey>`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Generate keys: "Create a new identity"
|
||||||
|
- `nak key generate`
|
||||||
|
- `nak key generate | nak key public | nak encode npub`
|
||||||
|
- Extract hex from NIP-05: "Get the raw pubkey from an address"
|
||||||
|
- `nak decode user@domain.com | jq -r .pubkey`
|
||||||
|
- Create shareable references: "Make a nevent with relay hints"
|
||||||
|
- `nak encode nevent <event_id> --relay <relay>`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Complex naddr creation: "Create addressable event reference with metadata"
|
||||||
|
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
|
||||||
|
- Multi-relay nprofile: "Create profile with multiple relay hints"
|
||||||
|
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
|
||||||
|
|
||||||
|
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 4: Event Creation & Publishing
|
||||||
|
*"Creating and signing new events"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Interactive creation: "Create an event with prompts"
|
||||||
|
- `nak event --prompt-sec <relay>`
|
||||||
|
- Simple note: "Publish a text note"
|
||||||
|
- `nak event -k 1 -c "Hello Nostr" <relay>`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Events with tags: "Create tagged content"
|
||||||
|
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
|
||||||
|
- Event deletion: "Delete a previous event"
|
||||||
|
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
|
||||||
|
- Replaceable events: "Create/update a profile or app data"
|
||||||
|
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Remote signing with bunker: "Sign without exposing keys"
|
||||||
|
- `nak event --connect "bunker://..." <relay>`
|
||||||
|
- Batch event creation: "Generate many events via script"
|
||||||
|
- `for i in {1..100}; do nak event localhost:8000; done`
|
||||||
|
- Complex events from JSON: "Craft specific event structure"
|
||||||
|
- Modify JSON, then `cat event.json | nak event <relay>`
|
||||||
|
|
||||||
|
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 5: Development & Testing
|
||||||
|
*"Building on Nostr"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Local relay testing: "Test against dev relay"
|
||||||
|
- `nak req localhost:8000`
|
||||||
|
- `nak event ws://127.0.0.1:7777`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Inspect JSON: "Examine event structure"
|
||||||
|
- `nak req -i <id> <relay> | jq .`
|
||||||
|
- Test search: "Verify search functionality"
|
||||||
|
- `nak req --search "<query>" localhost:8000`
|
||||||
|
- Admin operations: "Manage relay content"
|
||||||
|
- `nak admin --prompt-sec banevent --id <id> <relay>`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Protocol bridging: "Query/test ATProto integration"
|
||||||
|
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
|
||||||
|
- Git over Nostr: "Use git with Nostr transport"
|
||||||
|
- `nak git clone nostr://...`
|
||||||
|
- `nak req -k 30617 git.shakespeare.diy`
|
||||||
|
- Performance testing: "Measure query speed"
|
||||||
|
- `time nak req -k 0 -a <pubkey> <relay>`
|
||||||
|
- Custom event kinds: "Test proprietary event types"
|
||||||
|
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
|
||||||
|
|
||||||
|
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CONCEPT 6: Analytics & Monitoring
|
||||||
|
*"Understanding Nostr data"*
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
- Count results: "How many events match?"
|
||||||
|
- `nak req -k 1 <relay> | wc -l`
|
||||||
|
- `nak count -k 7 -e <event_id> <relay>`
|
||||||
|
|
||||||
|
**Intermediate:**
|
||||||
|
- Live monitoring: "Watch relay activity"
|
||||||
|
- `nak req --stream <relay>`
|
||||||
|
- `nak req -l 0 --stream relay1 relay2 relay3`
|
||||||
|
- Client analytics: "What apps are posting?"
|
||||||
|
- `nak req --tag client=<app> <relay>`
|
||||||
|
|
||||||
|
**Advanced:**
|
||||||
|
- Event chain analysis: "Track engagement"
|
||||||
|
- `nak count -k 7 -e <event_id> <relay>` (reactions)
|
||||||
|
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
|
||||||
|
- Content ranking: "Find top/hot content"
|
||||||
|
- `nak req --search "sort:top" <relay>`
|
||||||
|
- Cross-relay comparison: "Compare event availability"
|
||||||
|
- Query same event from multiple relays, compare results
|
||||||
|
|
||||||
|
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: The 6 Core Mental Models
|
||||||
|
|
||||||
|
1. **Query & Discovery**: "How do I find things?"
|
||||||
|
2. **Broadcast & Migration**: "How do I move things?"
|
||||||
|
3. **Identity & Encoding**: "How do I represent things?"
|
||||||
|
4. **Event Creation**: "How do I make things?"
|
||||||
|
5. **Development & Testing**: "How do I build things?"
|
||||||
|
6. **Analytics & Monitoring**: "How do I measure things?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Shapes and Edge-Cases
|
||||||
|
|
||||||
|
Non-obvious patterns and edge cases for nak commands.
|
||||||
|
|
||||||
|
### Signing Methods
|
||||||
|
|
||||||
|
**Using environment variable:**
|
||||||
|
```bash
|
||||||
|
export NOSTR_SECRET_KEY=<hex_key>
|
||||||
|
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reading key from file:**
|
||||||
|
```bash
|
||||||
|
nak event -c "hello" --sec $(cat /path/to/key.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content from File
|
||||||
|
|
||||||
|
**Using @ prefix to read content from file:**
|
||||||
|
```bash
|
||||||
|
echo "hello world" > content.txt
|
||||||
|
nak event -c @content.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag Syntax
|
||||||
|
|
||||||
|
**Tag with multiple values (semicolon-separated):**
|
||||||
|
```bash
|
||||||
|
nak event -t custom="value1;value2;value3"
|
||||||
|
# Creates: ["custom", "value1", "value2", "value3"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Output Modes
|
||||||
|
|
||||||
|
**Print bare filter (JSON only):**
|
||||||
|
```bash
|
||||||
|
nak req -k 1 -l 5 --bare
|
||||||
|
# Output: {"kinds":[1],"limit":5}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter from stdin can be modified with flags:**
|
||||||
|
```bash
|
||||||
|
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unlimited stream:**
|
||||||
|
```bash
|
||||||
|
nak req -l 0 --stream wss://relay.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relay Specification
|
||||||
|
|
||||||
|
**Local relays and WebSocket schemes:**
|
||||||
|
```bash
|
||||||
|
nak req localhost:8000
|
||||||
|
nak req ws://127.0.0.1:7777
|
||||||
|
nak req relay.example.com # Assumes wss:// if not specified
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encoding
|
||||||
|
|
||||||
|
**Encode from JSON stdin (auto-detects type):**
|
||||||
|
```bash
|
||||||
|
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Operations
|
||||||
|
|
||||||
|
**Complete key generation pipeline:**
|
||||||
|
```bash
|
||||||
|
nak key generate | tee secret.key | nak key public | nak encode npub
|
||||||
|
# Saves private key to file AND prints the public npub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
**Verify and pipe:**
|
||||||
|
```bash
|
||||||
|
nak event -c "test" --sec <key> | nak verify && echo "Valid"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch vs Req
|
||||||
|
|
||||||
|
**nak fetch uses relay hints from identifiers:**
|
||||||
|
```bash
|
||||||
|
nak fetch nevent1... # Uses relays encoded in nevent
|
||||||
|
nak fetch naddr1... # Uses relays encoded in naddr
|
||||||
|
nak fetch alex@gleasonator.dev # Resolves NIP-05
|
||||||
|
nak fetch -r relay.primal.net naddr1... # Override relays
|
||||||
|
```
|
||||||
|
|
||||||
|
**nak req requires explicit relay specification:**
|
||||||
|
```bash
|
||||||
|
nak req -i <event_id> wss://relay.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
**No relays specified (prints event without publishing):**
|
||||||
|
```bash
|
||||||
|
nak event -c "test" --sec <key> # Just prints the event JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tag order matters for addressable events:**
|
||||||
|
```bash
|
||||||
|
# The first 'd' tag is the identifier
|
||||||
|
nak event -k 30023 -d first -d second # "first" is the identifier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timestamp override:**
|
||||||
|
```bash
|
||||||
|
nak event --ts 1700000000 -c "backdated" --sec <key>
|
||||||
|
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kind 0 (profile) requires JSON content:**
|
||||||
|
```bash
|
||||||
|
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
**POW (Proof of Work):**
|
||||||
|
```bash
|
||||||
|
nak event --pow 20 -c "mined event" --sec <key>
|
||||||
|
# Will compute hash until difficulty target is met
|
||||||
|
```
|
||||||
|
|
||||||
|
**NIP-42 AUTH:**
|
||||||
|
```bash
|
||||||
|
nak req --auth -k 1 wss://relay.example.com
|
||||||
|
nak event --auth -c "test" --sec <key> wss://relay.example.com
|
||||||
|
# Automatically handles AUTH challenges
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stdin takes precedence over flags:**
|
||||||
|
```bash
|
||||||
|
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
|
||||||
|
# Uses "from stdin" (stdin overrides flags)
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: nostr
|
||||||
|
description: Knowledge about the Nostr protocol. Use to view up-to-date NIPs, discover capabilities of the Nostr protocol, and to implement Nostr functionality correctly.
|
||||||
|
license: CC-BY-SA-4.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nostr Protocol
|
||||||
|
|
||||||
|
Nostr is a simple, open protocol that enables truly censorship-resistant and global social networks using cryptographic keys and signatures.
|
||||||
|
|
||||||
|
## Finding Nostr Documentation
|
||||||
|
|
||||||
|
All Nostr protocol documentation is maintained in the NIPs (Nostr Implementation Possibilities) repository. To access this information:
|
||||||
|
|
||||||
|
### Reading Individual NIPs
|
||||||
|
|
||||||
|
Individual NIPs can be fetched from:
|
||||||
|
```
|
||||||
|
https://github.com/nostr-protocol/nips/blob/master/{NIP}.md
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, NIP-01 (the basic protocol specification) is available at:
|
||||||
|
```
|
||||||
|
https://github.com/nostr-protocol/nips/blob/master/01.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Event Kinds
|
||||||
|
|
||||||
|
Documentation for Nostr event kinds is spread across one or more NIPs. **There is no direct relationship between the NIP number and the kind number.**
|
||||||
|
|
||||||
|
To find which NIPs document a specific event kind:
|
||||||
|
|
||||||
|
1. First, fetch the README:
|
||||||
|
```
|
||||||
|
https://github.com/nostr-protocol/nips/blob/master/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Reference the "Event Kinds" table in the README to find which NIP(s) document the kind you're looking for
|
||||||
|
|
||||||
|
### Discovering Existing Capabilities
|
||||||
|
|
||||||
|
The README should be consulted to:
|
||||||
|
|
||||||
|
- **View the list of NIPs** - Use this to discover what capabilities already exist on the Nostr network
|
||||||
|
- **Review client and relay messages** - Understand the communication protocol
|
||||||
|
- **Check the list of tags** - See what standardized tags are available
|
||||||
|
- **Decide on using existing NIPs** - Before implementing a feature, check if an existing NIP already covers it
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
Always start by fetching and reviewing the README to understand the current state of the protocol and avoid reinventing existing functionality.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Agent Instructions for Marco
|
||||||
|
|
||||||
|
Marco is a privacy-respecting, unhosted maps application that allows users to connect their own remote storage to sync place bookmarks across apps and devices.
|
||||||
|
|
||||||
|
## Relevant Commands
|
||||||
|
|
||||||
|
All commands must be executed using `pnpm`. Never use `npm`.
|
||||||
|
|
||||||
|
- **Install dependencies:** `pnpm install`
|
||||||
|
- **Start development server:** `pnpm start` (Runs Vite)
|
||||||
|
- **Run tests:** `pnpm test` (Builds and runs via Testem)
|
||||||
|
- **Linting:**
|
||||||
|
- `pnpm lint` (Runs ESLint, Stylelint, Prettier, and ember-template-lint concurrently)
|
||||||
|
- `pnpm lint:fix` (Automatically fixes linting and formatting errors)
|
||||||
|
|
||||||
|
### NEVER RUN
|
||||||
|
|
||||||
|
- `pnpm build` (only run for releases by the devs)
|
||||||
|
- `git add`, `git commit`, or any other commands that modify git history or the staging area
|
||||||
|
|
||||||
|
## Application Architecture & Frameworks
|
||||||
|
|
||||||
|
### Core Stack
|
||||||
|
|
||||||
|
- **Framework:** Ember.js (Octane / Polaris editions). The project heavily uses modern Ember paradigms including `.gjs` (template-tag format), `@glimmer/component`, and `@glimmer/tracking`.
|
||||||
|
- **Build System:** Vite coupled with Embroider (`@embroider/vite`) for fast, modern asset compilation.
|
||||||
|
|
||||||
|
### Mapping & Geocoding
|
||||||
|
|
||||||
|
- **Maps:** Uses OpenLayers (`ol` and `ol-mapbox-style`) for rendering the map interface.
|
||||||
|
- **Search:** Uses the Photon API (via `app/services/photon.js`) for geocoding and search functionality.
|
||||||
|
- **Data Source:** OpenStreetMap (OSM) data is fetched and parsed to display rich place details.
|
||||||
|
|
||||||
|
### Storage & Authentication
|
||||||
|
|
||||||
|
- **RemoteStorage:** The app is "unhosted" meaning user data isn't locked in a central database. It uses `remotestoragejs` and `@remotestorage/module-places` to sync bookmarks to a user's chosen storage provider. (Managed in `app/services/storage.js`).
|
||||||
|
- **OSM Auth:** Allows users to log into OpenStreetMap using OAuth 2.0 PKCE (`oauth2-pkce`) to fetch user info and potentially interact with OSM directly (Managed in `app/services/osm-auth.js`).
|
||||||
|
|
||||||
|
### Directory Structure Highlights
|
||||||
|
|
||||||
|
- `app/components/`: UI components using `.gjs` (Glimmer JS with embedded `<template>` tags).
|
||||||
|
- `app/services/`: Core business logic, state management, and API integrations.
|
||||||
|
- `app/utils/`: Helper functions for geohashing, parsing OSM tags, icon mapping, and link formatting.
|
||||||
|
- `tests/`: Comprehensive QUnit test suite containing unit, integration, and acceptance tests.
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
|
||||||
|
- `ember-concurrency` / `ember-lifeline`: Managing async tasks and debouncing.
|
||||||
|
- `remotestorage-widget`: The UI widget for connecting to a RemoteStorage provider.
|
||||||
@@ -7,10 +7,15 @@ import Icon from '#components/icon';
|
|||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
import SearchBox from '#components/search-box';
|
import SearchBox from '#components/search-box';
|
||||||
import CategoryChips from '#components/category-chips';
|
import CategoryChips from '#components/category-chips';
|
||||||
|
import { and } from 'ember-truth-helpers';
|
||||||
|
import cachedImage from '../modifiers/cached-image';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service mapUi;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
@tracked searchQuery = '';
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
@@ -18,6 +23,11 @@ export default class AppHeaderComponent extends Component {
|
|||||||
return !!this.searchQuery;
|
return !!this.searchQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showQuickSearch() {
|
||||||
|
const zoom = this.mapUi.currentZoom ?? 13;
|
||||||
|
return this.settings.showQuickSearchButtons && zoom >= 12;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
this.isUserMenuOpen = !this.isUserMenuOpen;
|
this.isUserMenuOpen = !this.isUserMenuOpen;
|
||||||
@@ -50,7 +60,7 @@ 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.hasQuery 'searching'}}">
|
||||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
@@ -64,9 +74,19 @@ export default class AppHeaderComponent extends Component {
|
|||||||
aria-label="User Menu"
|
aria-label="User Menu"
|
||||||
{{on "click" this.toggleUserMenu}}
|
{{on "click" this.toggleUserMenu}}
|
||||||
>
|
>
|
||||||
<div class="user-avatar-placeholder">
|
{{#if
|
||||||
<Icon @name="user" @size={{20}} @color="white" />
|
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
|
||||||
</div>
|
}}
|
||||||
|
<img
|
||||||
|
{{cachedImage this.nostrData.profile.picture}}
|
||||||
|
class="user-avatar"
|
||||||
|
alt="User Avatar"
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<div class="user-avatar-placeholder">
|
||||||
|
<Icon @name="user" @size={{20}} @color="white" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{#if this.isUserMenuOpen}}
|
{{#if this.isUserMenuOpen}}
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
import { service } from '@ember/service';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import AppMenuSettingsMapUi from './settings/map-ui';
|
||||||
|
import AppMenuSettingsApis from './settings/apis';
|
||||||
|
import AppMenuSettingsNostr from './settings/nostr';
|
||||||
|
import AppMenuSettingsExperimental from './settings/experimental';
|
||||||
|
|
||||||
export default class AppMenuSettings extends Component {
|
export default class AppMenuSettings extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateApi(event) {
|
updateSetting(key, event) {
|
||||||
this.settings.updateOverpassApi(event.target.value);
|
let value = event.target.value;
|
||||||
}
|
if (value === 'true') value = true;
|
||||||
|
if (value === 'false') value = false;
|
||||||
|
|
||||||
@action
|
this.settings.update(key, value);
|
||||||
toggleKinetic(event) {
|
|
||||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
toggleQuickSearchButtons(event) {
|
|
||||||
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updatePhotonApi(event) {
|
|
||||||
this.settings.updatePhotonApi(event.target.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -41,88 +33,10 @@ export default class AppMenuSettings extends Component {
|
|||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<div class="form-group">
|
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
|
||||||
<label for="show-quick-search">Quick search buttons visible</label>
|
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
|
||||||
<select
|
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
|
||||||
id="show-quick-search"
|
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.toggleQuickSearchButtons}}
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
value="true"
|
|
||||||
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
|
||||||
>
|
|
||||||
Yes
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="false"
|
|
||||||
selected={{unless
|
|
||||||
this.settings.showQuickSearchButtons
|
|
||||||
"selected"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
|
||||||
<select
|
|
||||||
id="map-kinetic"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.toggleKinetic}}
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
value="true"
|
|
||||||
selected={{if this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
On
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="false"
|
|
||||||
selected={{unless this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
Off
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="overpass-api">Overpass API Provider</label>
|
|
||||||
<select
|
|
||||||
id="overpass-api"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.updateApi}}
|
|
||||||
>
|
|
||||||
{{#each this.settings.overpassApis as |api|}}
|
|
||||||
<option
|
|
||||||
value={{api.url}}
|
|
||||||
selected={{if
|
|
||||||
(eq api.url this.settings.overpassApi)
|
|
||||||
"selected"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{{api.name}}
|
|
||||||
</option>
|
|
||||||
{{/each}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="photon-api">Photon API Provider</label>
|
|
||||||
<select
|
|
||||||
id="photon-api"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.updatePhotonApi}}
|
|
||||||
>
|
|
||||||
{{#each this.settings.photonApis as |api|}}
|
|
||||||
<option
|
|
||||||
value={{api.url}}
|
|
||||||
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
|
||||||
>
|
|
||||||
{{api.name}}
|
|
||||||
</option>
|
|
||||||
{{/each}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
export default class AppMenuSettingsApis extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="server" @size={{20}} />
|
||||||
|
<span>API Providers</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="overpass-api">Overpass API Provider</label>
|
||||||
|
<select
|
||||||
|
id="overpass-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "overpassApi")}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.overpassApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if
|
||||||
|
(eq api.url this.settings.overpassApi)
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="photon-api">Photon API Provider</label>
|
||||||
|
<select
|
||||||
|
id="photon-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "photonApi")}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.photonApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
export default class AppMenuSettingsMapUi extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="map" @size={{20}} />
|
||||||
|
<span>Map & UI</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="show-quick-search">Quick search buttons visible</label>
|
||||||
|
<select
|
||||||
|
id="show-quick-search"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "showQuickSearchButtons")}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless
|
||||||
|
this.settings.showQuickSearchButtons
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||||
|
<select
|
||||||
|
id="map-kinetic"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "mapKinetic")}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { normalizeRelayUrl } from '../../../utils/nostr';
|
||||||
|
|
||||||
|
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||||
|
|
||||||
|
export default class AppMenuSettingsNostr extends Component {
|
||||||
|
@service settings;
|
||||||
|
@service nostrData;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
@tracked newReadRelay = '';
|
||||||
|
@tracked newWriteRelay = '';
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateNewReadRelay(event) {
|
||||||
|
this.newReadRelay = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateNewWriteRelay(event) {
|
||||||
|
this.newWriteRelay = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
addReadRelay() {
|
||||||
|
const url = normalizeRelayUrl(this.newReadRelay);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const current =
|
||||||
|
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||||
|
const set = new Set([...current, url]);
|
||||||
|
this.settings.update('nostrReadRelays', Array.from(set));
|
||||||
|
this.newReadRelay = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeReadRelay(url) {
|
||||||
|
const current =
|
||||||
|
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
|
||||||
|
const filtered = current.filter((r) => r !== url);
|
||||||
|
this.settings.update('nostrReadRelays', filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleReadRelayKeydown(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.addReadRelay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleWriteRelayKeydown(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
this.addWriteRelay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
resetReadRelays() {
|
||||||
|
this.settings.update('nostrReadRelays', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
addWriteRelay() {
|
||||||
|
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const current =
|
||||||
|
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||||
|
const set = new Set([...current, url]);
|
||||||
|
this.settings.update('nostrWriteRelays', Array.from(set));
|
||||||
|
this.newWriteRelay = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeWriteRelay(url) {
|
||||||
|
const current =
|
||||||
|
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
|
||||||
|
const filtered = current.filter((r) => r !== url);
|
||||||
|
this.settings.update('nostrWriteRelays', filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
resetWriteRelays() {
|
||||||
|
this.settings.update('nostrWriteRelays', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async clearCache() {
|
||||||
|
try {
|
||||||
|
await this.nostrData.clearCache();
|
||||||
|
this.toast.show('Nostr cache cleared');
|
||||||
|
} catch (e) {
|
||||||
|
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="zap" @size={{20}} />
|
||||||
|
<span>Nostr</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content form-layout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-read-relay">Read Relays</label>
|
||||||
|
<ul class="relay-list">
|
||||||
|
{{#each this.nostrData.activeReadRelays as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay}}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeReadRelay relay)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="add-relay-input">
|
||||||
|
<input
|
||||||
|
id="new-read-relay"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
value={{this.newReadRelay}}
|
||||||
|
{{on "input" this.updateNewReadRelay}}
|
||||||
|
{{on "keydown" this.handleReadRelayKeydown}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
{{on "click" this.addReadRelay}}
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.settings.nostrReadRelays}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetReadRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-write-relay">Write Relays</label>
|
||||||
|
<ul class="relay-list">
|
||||||
|
{{#each this.nostrData.activeWriteRelays as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay}}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-relay"
|
||||||
|
title="Remove relay"
|
||||||
|
aria-label="Remove"
|
||||||
|
{{on "click" (fn this.removeWriteRelay relay)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="add-relay-input">
|
||||||
|
<input
|
||||||
|
id="new-write-relay"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
value={{this.newWriteRelay}}
|
||||||
|
{{on "input" this.updateNewWriteRelay}}
|
||||||
|
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
{{on "click" this.addWriteRelay}}
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.settings.nostrWriteRelays}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetWriteRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||||
|
servers</label>
|
||||||
|
<select
|
||||||
|
id="nostr-photo-fallback-uploads"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless
|
||||||
|
this.settings.nostrPhotoFallbackUploads
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Cached data</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-full"
|
||||||
|
{{on "click" this.clearCache}}
|
||||||
|
>
|
||||||
|
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
|
||||||
|
Clear profiles, photos, and reviews
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
|
export default class Blurhash extends Component {
|
||||||
|
renderBlurhash = modifier((canvas, [hash, width, height]) => {
|
||||||
|
if (!hash || !canvas) return;
|
||||||
|
|
||||||
|
// Default size to a small multiple of aspect ratio to save CPU
|
||||||
|
// 32x18 is a good balance of speed vs quality for 16:9
|
||||||
|
const renderWidth = width || 32;
|
||||||
|
const renderHeight = height || 18;
|
||||||
|
|
||||||
|
canvas.width = renderWidth;
|
||||||
|
canvas.height = renderHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pixels = decode(hash, renderWidth, renderHeight);
|
||||||
|
const imageData = ctx.createImageData(renderWidth, renderHeight);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode blurhash:', e.message || e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
class="blurhash-canvas"
|
||||||
|
...attributes
|
||||||
|
{{this.renderBlurhash @hash @width @height}}
|
||||||
|
></canvas>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -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
-13
@@ -20,6 +20,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
|||||||
import { apply } from 'ol-mapbox-style';
|
import { apply } from 'ol-mapbox-style';
|
||||||
import { getIcon } from '../utils/icons';
|
import { getIcon } from '../utils/icons';
|
||||||
import { getIconNameForTags } from '../utils/osm-icons';
|
import { getIconNameForTags } from '../utils/osm-icons';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -27,6 +28,7 @@ export default class MapComponent extends Component {
|
|||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
@@ -283,8 +285,28 @@ 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');
|
if (config.environment === 'test') {
|
||||||
|
apply(this.mapInstance, {
|
||||||
|
version: 8,
|
||||||
|
name: 'Test Style',
|
||||||
|
sources: {},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'background',
|
||||||
|
type: 'background',
|
||||||
|
paint: {
|
||||||
|
'background-color': '#f8f4f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty', {
|
||||||
|
webfonts: 'data:text/css,',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.searchOverlayElement = document.createElement('div');
|
this.searchOverlayElement = document.createElement('div');
|
||||||
this.searchOverlayElement.className = 'search-pulse';
|
this.searchOverlayElement.className = 'search-pulse';
|
||||||
@@ -392,7 +414,10 @@ export default class MapComponent extends Component {
|
|||||||
const locateElement = document.createElement('div');
|
const locateElement = document.createElement('div');
|
||||||
locateElement.className = 'ol-control ol-locate';
|
locateElement.className = 'ol-control ol-locate';
|
||||||
const locateBtn = document.createElement('button');
|
const locateBtn = document.createElement('button');
|
||||||
locateBtn.innerHTML = '⊙';
|
locateBtn.style.display = 'flex';
|
||||||
|
locateBtn.style.alignItems = 'center';
|
||||||
|
locateBtn.style.justifyContent = 'center';
|
||||||
|
locateBtn.innerHTML = `<span class="icon" style="width: 14px; height: 14px; display: flex;">${getIcon('navigation')}</span>`;
|
||||||
locateBtn.title = 'Locate Me';
|
locateBtn.title = 'Locate Me';
|
||||||
locateElement.appendChild(locateBtn);
|
locateElement.appendChild(locateBtn);
|
||||||
|
|
||||||
@@ -776,14 +801,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).
|
||||||
@@ -843,6 +873,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];
|
||||||
@@ -850,7 +883,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,11 +904,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,11 +1060,12 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMapMove = async () => {
|
handleMapMove = async () => {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance || this.isDestroying || this.isDestroyed) return;
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const center = toLonLat(view.getCenter());
|
const center = toLonLat(view.getCenter());
|
||||||
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) {
|
||||||
@@ -1073,6 +1106,7 @@ export default class MapComponent extends Component {
|
|||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
this.mapUi.updateBounds(bbox);
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
|
this.nostrData.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
// Persist view to localStorage
|
// Persist view to localStorage
|
||||||
@@ -1137,6 +1171,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
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 {
|
||||||
|
get isTesting() {
|
||||||
|
return config.environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationElement() {
|
||||||
|
return document.getElementById('modal-portal') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
stopProp(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
if (this.args.disableClose) return;
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.isTesting}}
|
||||||
|
<ModalContent
|
||||||
|
@close={{this.close}}
|
||||||
|
@stopProp={{this.stopProp}}
|
||||||
|
@disableClose={{@disableClose}}
|
||||||
|
>
|
||||||
|
{{yield}}
|
||||||
|
</ModalContent>
|
||||||
|
{{else}}
|
||||||
|
{{#in-element this.destinationElement}}
|
||||||
|
<ModalContent
|
||||||
|
@close={{this.close}}
|
||||||
|
@stopProp={{this.stopProp}}
|
||||||
|
@disableClose={{@disableClose}}
|
||||||
|
>
|
||||||
|
{{yield}}
|
||||||
|
</ModalContent>
|
||||||
|
{{/in-element}}
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { eq } from 'ember-truth-helpers';
|
||||||
|
import qrCode from '../modifiers/qr-code';
|
||||||
|
|
||||||
|
export default class NostrConnectComponent extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
get hasExtension() {
|
||||||
|
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectExtension() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.connectWithExtension();
|
||||||
|
this.toast.show('Nostr connected successfully');
|
||||||
|
if (this.args.onConnect) {
|
||||||
|
this.args.onConnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectApp() {
|
||||||
|
try {
|
||||||
|
await this.nostrAuth.connectWithApp();
|
||||||
|
this.toast.show('Nostr connected successfully');
|
||||||
|
if (this.args.onConnect) {
|
||||||
|
this.args.onConnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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>
|
||||||
|
<div class="nostr-connect-modal">
|
||||||
|
<h2>Connect with Nostr</h2>
|
||||||
|
|
||||||
|
<div class="nostr-connect-options">
|
||||||
|
{{#if this.hasExtension}}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectExtension}}
|
||||||
|
>
|
||||||
|
Browser Extension (nos2x, Alby)
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="No Nostr extension found in your browser"
|
||||||
|
>
|
||||||
|
Browser Extension (Not Found)
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectApp}}
|
||||||
|
>
|
||||||
|
Mobile Signer App (Amber, etc.)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
|
||||||
|
<div class="nostr-connect-status">
|
||||||
|
{{#if this.nostrAuth.isMobile}}
|
||||||
|
<p>Waiting for you to approve the connection in your mobile signer
|
||||||
|
app...</p>
|
||||||
|
<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}}
|
||||||
|
<p>Scan this QR code with a Nostr signer app (like Amber):</p>
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<canvas {{qrCode this.nostrAuth.connectUri}}></canvas>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -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={{@name}}
|
||||||
|
{{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={{@name}}
|
||||||
|
{{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={{@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}}
|
||||||
|
{{/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,295 @@
|
|||||||
|
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-factory';
|
||||||
|
import config from 'marco/config/environment';
|
||||||
|
import DropdownMenu from './dropdown-menu';
|
||||||
|
import PhotoCarousel from './photo-carousel';
|
||||||
|
import Icon from './icon';
|
||||||
|
|
||||||
|
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="actions-btn-container">
|
||||||
|
<DropdownMenu
|
||||||
|
@iconSize={{24}}
|
||||||
|
@triggerIcon="more-horizontal"
|
||||||
|
@iconColor="white"
|
||||||
|
as |closeMenu|
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @copyEventId closeMenu)}}
|
||||||
|
>Copy Photo Event ID</button>
|
||||||
|
{{#if @canDeletePhoto}}
|
||||||
|
<button
|
||||||
|
class="dropdown-item text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||||
|
>Delete Photo</button>
|
||||||
|
{{/if}}
|
||||||
|
</DropdownMenu>
|
||||||
|
</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>
|
||||||
|
</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 blossom;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||||
|
|
||||||
|
get isCreator() {
|
||||||
|
return (
|
||||||
|
this.currentPhoto?.pubkey &&
|
||||||
|
this.nostrAuth.pubkey &&
|
||||||
|
this.currentPhoto.pubkey === this.nostrAuth.pubkey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canDeletePhoto() {
|
||||||
|
return (
|
||||||
|
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('.actions-btn-container')
|
||||||
|
) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const tags = [['e', eventId]];
|
||||||
|
|
||||||
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
kind: 5,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: 'Deleted photo',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
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}}
|
||||||
|
/>
|
||||||
|
{{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}}
|
||||||
|
/>
|
||||||
|
{{/in-element}}
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -6,17 +6,77 @@ import { humanizeOsmTag } from '../utils/format-text';
|
|||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
import { mapToStorageSchema } from '../utils/place-mapping';
|
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||||
import { getSocialInfo } from '../utils/social-links';
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
|
import { parsePlacePhotos } from '../utils/nostr';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
import PlaceListsManager from './place-lists-manager';
|
import PlaceListsManager from './place-lists-manager';
|
||||||
|
import PlacePhotoUpload from './place-photo-upload';
|
||||||
|
import NostrConnect from './nostr-connect';
|
||||||
|
import Modal from './modal';
|
||||||
|
import 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';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
|
@tracked isPhotoUploadActive = false;
|
||||||
|
@tracked isConnectingNostr = false;
|
||||||
|
@tracked isGalleryOpen = false;
|
||||||
|
@tracked selectedGalleryPhoto = null;
|
||||||
|
@tracked isPhotoUploadModalOpen = false;
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
@tracked newlyUploadedPhotoId = null;
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleUploadStateChange(isActive) {
|
||||||
|
this.isPhotoUploadActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openPhotoUploadModal(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
} else {
|
||||||
|
this.isPhotoUploadModalOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closePhotoUploadModal(eventId) {
|
||||||
|
if (this.isPhotoUploadActive) return;
|
||||||
|
this.isPhotoUploadModalOpen = false;
|
||||||
|
if (typeof eventId === 'string') {
|
||||||
|
this.newlyUploadedPhotoId = eventId;
|
||||||
|
|
||||||
|
// Allow DOM to update first, then scroll to the top to show the new photo in the carousel
|
||||||
|
setTimeout(() => {
|
||||||
|
const sidebar = document.querySelector('.sidebar-content');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onNostrConnected() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
this.isPhotoUploadModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
get isSaved() {
|
get isSaved() {
|
||||||
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||||
@@ -42,6 +102,16 @@ export default class PlaceDetails extends Component {
|
|||||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get photos() {
|
||||||
|
const rawPhotos = this.nostrData.placePhotos;
|
||||||
|
const parsedPhotos = parsePlacePhotos(rawPhotos);
|
||||||
|
|
||||||
|
return parsedPhotos.map((photo) => ({
|
||||||
|
...photo,
|
||||||
|
style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.isSaved) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
@@ -130,15 +200,24 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
formatMultiLine(val, type) {
|
formatMultiLine(val, type) {
|
||||||
if (!val) return null;
|
if (!val) return null;
|
||||||
const parts = val
|
const parts = [
|
||||||
.split(';')
|
...new Set(
|
||||||
.map((s) => s.trim())
|
val
|
||||||
.filter(Boolean);
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
|
];
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
if (type === 'phone') {
|
if (type === 'phone') {
|
||||||
return htmlSafe(
|
return htmlSafe(
|
||||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
parts
|
||||||
|
.map((p) => {
|
||||||
|
const safeTel = p.replace(/[\s-]+/g, '');
|
||||||
|
return `<a href="tel:${safeTel}">${p}</a>`;
|
||||||
|
})
|
||||||
|
.join('<br>')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +227,17 @@ export default class PlaceDetails extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'whatsapp') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts
|
||||||
|
.map((p) => {
|
||||||
|
const safeTel = p.replace(/[\s-]+/g, '');
|
||||||
|
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
|
||||||
|
})
|
||||||
|
.join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'url') {
|
if (type === 'url') {
|
||||||
return htmlSafe(
|
return htmlSafe(
|
||||||
parts
|
parts
|
||||||
@@ -165,8 +255,27 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get phone() {
|
get phone() {
|
||||||
const val = this.tags.phone || this.tags['contact:phone'];
|
const rawValues = [
|
||||||
return this.formatMultiLine(val, 'phone');
|
this.tags.phone,
|
||||||
|
this.tags['contact:phone'],
|
||||||
|
this.tags.mobile,
|
||||||
|
this.tags['contact:mobile'],
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (rawValues.length === 0) return null;
|
||||||
|
|
||||||
|
return this.formatMultiLine(rawValues.join(';'), 'phone');
|
||||||
|
}
|
||||||
|
|
||||||
|
get whatsapp() {
|
||||||
|
const rawValues = [
|
||||||
|
this.tags.whatsapp,
|
||||||
|
this.tags['contact:whatsapp'],
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (rawValues.length === 0) return null;
|
||||||
|
|
||||||
|
return this.formatMultiLine(rawValues.join(';'), 'whatsapp');
|
||||||
}
|
}
|
||||||
|
|
||||||
get email() {
|
get email() {
|
||||||
@@ -257,6 +366,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}}
|
||||||
@@ -266,6 +387,14 @@ export default class PlaceDetails extends Component {
|
|||||||
@onCancel={{this.cancelEditing}}
|
@onCancel={{this.cancelEditing}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<PhotoCarousel
|
||||||
|
@variant="inline"
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@name={{this.name}}
|
||||||
|
@resetKey={{this.place.osmId}}
|
||||||
|
@scrollToEventId={{this.newlyUploadedPhotoId}}
|
||||||
|
@onPhotoClick={{this.openGallery}}
|
||||||
|
/>
|
||||||
<h3>{{this.name}}</h3>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
{{this.type}}
|
{{this.type}}
|
||||||
@@ -343,6 +472,15 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.whatsapp}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="whatsapp" @title="WhatsApp" />
|
||||||
|
<span>
|
||||||
|
{{this.whatsapp}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.website}}
|
{{#if this.website}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="globe" @title="Website" />
|
<Icon @name="globe" @title="Website" />
|
||||||
@@ -452,6 +590,51 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.osmUrl}}
|
||||||
|
<div class="meta-info">
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="feather-camera" />
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link"
|
||||||
|
{{on "click" this.openPhotoUploadModal}}
|
||||||
|
>
|
||||||
|
Add a photo
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isPhotoUploadModalOpen}}
|
||||||
|
<Modal
|
||||||
|
@onClose={{this.closePhotoUploadModal}}
|
||||||
|
@disableClose={{this.isPhotoUploadActive}}
|
||||||
|
>
|
||||||
|
<PlacePhotoUpload
|
||||||
|
@place={{this.saveablePlace}}
|
||||||
|
@onClose={{this.closePhotoUploadModal}}
|
||||||
|
@onUploadStateChange={{this.handleUploadStateChange}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isGalleryOpen}}
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedGalleryPhoto}}
|
||||||
|
@placeName={{this.name}}
|
||||||
|
@onClose={{this.closeGallery}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { isMobile } from '../utils/device';
|
||||||
|
import Blurhash from './blurhash';
|
||||||
|
|
||||||
|
const MAX_IMAGE_DIMENSION = 1920;
|
||||||
|
const IMAGE_QUALITY = 0.9;
|
||||||
|
const MAX_THUMBNAIL_DIMENSION = 350;
|
||||||
|
const THUMBNAIL_QUALITY = 0.9;
|
||||||
|
|
||||||
|
export default class PlacePhotoUploadItem extends Component {
|
||||||
|
@service blossom;
|
||||||
|
@service imageProcessor;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
@tracked thumbnailUrl = '';
|
||||||
|
@tracked blurhash = '';
|
||||||
|
@tracked error = '';
|
||||||
|
@tracked statusText = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
if (this.args.file) {
|
||||||
|
this.thumbnailUrl = URL.createObjectURL(this.args.file);
|
||||||
|
this.uploadTask.perform(this.args.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
if (this.thumbnailUrl) {
|
||||||
|
URL.revokeObjectURL(this.thumbnailUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
showErrorToast() {
|
||||||
|
if (this.error) {
|
||||||
|
this.toast.show(this.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTask = task(async (file) => {
|
||||||
|
this.error = '';
|
||||||
|
this.statusText = 'Processing';
|
||||||
|
try {
|
||||||
|
// 1. Process main image and generate blurhash in worker
|
||||||
|
const mainData = await this.imageProcessor.process(
|
||||||
|
file,
|
||||||
|
MAX_IMAGE_DIMENSION,
|
||||||
|
IMAGE_QUALITY,
|
||||||
|
true // computeBlurhash
|
||||||
|
);
|
||||||
|
|
||||||
|
this.blurhash = mainData.blurhash;
|
||||||
|
|
||||||
|
// 2. Process thumbnail (no blurhash needed)
|
||||||
|
const thumbData = await this.imageProcessor.process(
|
||||||
|
file,
|
||||||
|
MAX_THUMBNAIL_DIMENSION,
|
||||||
|
THUMBNAIL_QUALITY,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Upload main image
|
||||||
|
// 4. Upload thumbnail
|
||||||
|
let mainResult, thumbResult;
|
||||||
|
const isMobileDevice = isMobile();
|
||||||
|
|
||||||
|
const mainProgress = (status) => {
|
||||||
|
if (status === 'signing') this.statusText = 'Signing photo upload';
|
||||||
|
if (status === 'uploading') this.statusText = 'Uploading photo';
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbProgress = (status) => {
|
||||||
|
if (status === 'signing') this.statusText = 'Signing thumbnail upload';
|
||||||
|
if (status === 'uploading') this.statusText = 'Uploading thumbnail';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMobileDevice) {
|
||||||
|
// Mobile: sequential uploads to preserve bandwidth and memory
|
||||||
|
mainResult = await this.blossom.upload(mainData.blob, {
|
||||||
|
sequential: true,
|
||||||
|
onProgress: mainProgress,
|
||||||
|
});
|
||||||
|
thumbResult = await this.blossom.upload(thumbData.blob, {
|
||||||
|
sequential: true,
|
||||||
|
onProgress: thumbProgress,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Desktop: concurrent uploads
|
||||||
|
const mainUploadPromise = this.blossom.upload(mainData.blob, {
|
||||||
|
onProgress: mainProgress,
|
||||||
|
});
|
||||||
|
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
|
||||||
|
onProgress: thumbProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
[mainResult, thumbResult] = await Promise.all([
|
||||||
|
mainUploadPromise,
|
||||||
|
thumbUploadPromise,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.onSuccess) {
|
||||||
|
this.args.onSuccess({
|
||||||
|
file,
|
||||||
|
url: mainResult.url,
|
||||||
|
fallbackUrls: mainResult.fallbackUrls,
|
||||||
|
thumbUrl: thumbResult.url,
|
||||||
|
blurhash: mainData.blurhash,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
dim: mainData.dim,
|
||||||
|
hash: mainResult.hash,
|
||||||
|
thumbHash: thumbResult.hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="photo-upload-item
|
||||||
|
{{if this.uploadTask.isRunning 'is-uploading'}}
|
||||||
|
{{if this.error 'has-error'}}"
|
||||||
|
>
|
||||||
|
{{#if this.blurhash}}
|
||||||
|
<Blurhash @hash={{this.blurhash}} class="place-header-photo-blur" />
|
||||||
|
{{/if}}
|
||||||
|
<img src={{this.thumbnailUrl}} alt="thumbnail" />
|
||||||
|
|
||||||
|
{{#if this.uploadTask.isRunning}}
|
||||||
|
<div class="overlay">
|
||||||
|
<Icon
|
||||||
|
@name="loading-ring"
|
||||||
|
@size={{24}}
|
||||||
|
@color="white"
|
||||||
|
class="spin-animation"
|
||||||
|
/>
|
||||||
|
{{#if this.statusText}}
|
||||||
|
<span class="upload-status-text">{{this.statusText}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="overlay error-overlay"
|
||||||
|
title={{this.error}}
|
||||||
|
{{on "click" this.showErrorToast}}
|
||||||
|
>
|
||||||
|
<Icon @name="alert-circle" @size={{24}} @color="white" />
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-photo"
|
||||||
|
title="Remove photo"
|
||||||
|
{{on "click" (fn @onRemove @file)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{16}} @color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import Geohash from 'latlon-geohash';
|
||||||
|
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
export default class PlacePhotoUpload extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrRelay;
|
||||||
|
@service nostrData;
|
||||||
|
@service blossom;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
@tracked file = null;
|
||||||
|
@tracked uploadedPhoto = null;
|
||||||
|
@tracked error = '';
|
||||||
|
@tracked isPublishing = false;
|
||||||
|
@tracked isDragging = false;
|
||||||
|
@tracked selectedTags = [];
|
||||||
|
|
||||||
|
get place() {
|
||||||
|
return this.args.place || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.place.title || 'this place';
|
||||||
|
}
|
||||||
|
|
||||||
|
get allUploaded() {
|
||||||
|
return (
|
||||||
|
this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get suggestedTags() {
|
||||||
|
return getSuggestedPhotoTags(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleFileSelect(event) {
|
||||||
|
this.addFile(event.target.files[0]);
|
||||||
|
event.target.value = ''; // Reset input
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDragOver(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
if (event.dataTransfer.files.length > 0) {
|
||||||
|
this.addFile(event.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addFile(file) {
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
this.error = 'Please select a valid image file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = '';
|
||||||
|
// If a photo was already uploaded but not published, delete it from the server
|
||||||
|
if (this.uploadedPhoto) {
|
||||||
|
this.deletePhotoTask.perform(this.uploadedPhoto);
|
||||||
|
}
|
||||||
|
this.file = file;
|
||||||
|
this.uploadedPhoto = null;
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleUploadSuccess(photoData) {
|
||||||
|
this.uploadedPhoto = photoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeFile() {
|
||||||
|
if (this.uploadedPhoto) {
|
||||||
|
this.deletePhotoTask.perform(this.uploadedPhoto);
|
||||||
|
}
|
||||||
|
this.file = null;
|
||||||
|
this.uploadedPhoto = null;
|
||||||
|
this.selectedTags = [];
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleTag(tag) {
|
||||||
|
if (this.selectedTags.includes(tag)) {
|
||||||
|
this.selectedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTags = [tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async (photoData) => {
|
||||||
|
try {
|
||||||
|
if (photoData.hash) {
|
||||||
|
await this.blossom.delete(photoData.hash);
|
||||||
|
}
|
||||||
|
if (photoData.thumbHash) {
|
||||||
|
await this.blossom.delete(photoData.thumbHash);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
@action
|
||||||
|
async publish() {
|
||||||
|
if (!this.nostrAuth.isConnected) {
|
||||||
|
this.error = 'You must connect Nostr first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.allUploaded) {
|
||||||
|
this.error = 'Please wait for all photos to finish uploading.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { osmId, lat, lon } = this.place;
|
||||||
|
const osmType = this.place.osmType || 'node';
|
||||||
|
|
||||||
|
if (!osmId) {
|
||||||
|
this.error = 'This place does not have a valid OSM ID.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error = '';
|
||||||
|
this.isPublishing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
|
||||||
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
for (const tag of this.selectedTags) {
|
||||||
|
tags.push(['t', tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 7)]);
|
||||||
|
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = this.uploadedPhoto;
|
||||||
|
const imeta = ['imeta', `url ${photo.url}`];
|
||||||
|
|
||||||
|
imeta.push(`m ${photo.type}`);
|
||||||
|
|
||||||
|
if (photo.dim) {
|
||||||
|
imeta.push(`dim ${photo.dim}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
imeta.push('alt A photo of a place');
|
||||||
|
|
||||||
|
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||||
|
for (const fallbackUrl of photo.fallbackUrls) {
|
||||||
|
imeta.push(`fallback ${fallbackUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.thumbUrl) {
|
||||||
|
imeta.push(`thumb ${photo.thumbUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.blurhash) {
|
||||||
|
imeta.push(`blurhash ${photo.blurhash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(imeta);
|
||||||
|
|
||||||
|
// NIP-XX draft Place Photo event
|
||||||
|
const template = {
|
||||||
|
kind: 360,
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!template.created_at) {
|
||||||
|
template.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await factory.sign(template);
|
||||||
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
|
this.toast.show('Photo published successfully');
|
||||||
|
|
||||||
|
// Clear out the file so user can upload more or be done
|
||||||
|
this.file = null;
|
||||||
|
this.uploadedPhoto = null;
|
||||||
|
|
||||||
|
if (this.args.onUploadStateChange) {
|
||||||
|
this.args.onUploadStateChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose(event.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to publish: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
this.isPublishing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-photo-upload">
|
||||||
|
<h2>Add Photo for {{this.title}}</h2>
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
{{this.error}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.file}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
<PlacePhotoUploadItem
|
||||||
|
@file={{this.file}}
|
||||||
|
@onSuccess={{this.handleUploadSuccess}}
|
||||||
|
@onRemove={{this.removeFile}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#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}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-publish"
|
||||||
|
disabled={{or (not this.allUploaded) this.isPublishing}}
|
||||||
|
{{on "click" this.publish}}
|
||||||
|
>
|
||||||
|
{{#if this.isPublishing}}
|
||||||
|
Publishing...
|
||||||
|
{{else}}
|
||||||
|
Publish Photo
|
||||||
|
{{/if}}
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<div
|
||||||
|
class="dropzone {{if this.isDragging 'is-dragging'}}"
|
||||||
|
{{on "dragover" this.handleDragOver}}
|
||||||
|
{{on "dragleave" this.handleDragLeave}}
|
||||||
|
{{on "drop" this.handleDrop}}
|
||||||
|
>
|
||||||
|
<label for="photo-upload-input" class="dropzone-label">
|
||||||
|
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
|
||||||
|
<p>Drag and drop a photo here, or click to browse</p>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="photo-upload-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="file-input-hidden"
|
||||||
|
disabled={{this.isPublishing}}
|
||||||
|
{{on "change" this.handleFileSelect}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
createNewPlace() {
|
createNewPlace() {
|
||||||
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
|
|||||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasHeaderPhoto() {
|
||||||
|
return (
|
||||||
|
this.args.selectedPlace &&
|
||||||
|
this.nostrData.placePhotos &&
|
||||||
|
this.nostrData.placePhotos.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ import { action } from '@ember/object';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import Modal from './modal';
|
||||||
|
import NostrConnect from './nostr-connect';
|
||||||
|
|
||||||
export default class UserMenuComponent extends Component {
|
export default class UserMenuComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osmAuth;
|
@service osmAuth;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connectRS() {
|
connectRS() {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
this.args.storage.connect();
|
this.args.storage.showConnectWidget();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -30,6 +37,21 @@ export default class UserMenuComponent extends Component {
|
|||||||
this.osmAuth.logout();
|
this.osmAuth.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
openNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
disconnectNostr() {
|
||||||
|
this.nostrAuth.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-menu-popover">
|
<div class="user-menu-popover">
|
||||||
<ul class="account-list">
|
<ul class="account-list">
|
||||||
@@ -55,7 +77,7 @@ export default class UserMenuComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
{{#if @storage.connected}}
|
{{#if @storage.connected}}
|
||||||
{{@storage.userAddress}}
|
<strong>{{@storage.userAddress}}</strong>
|
||||||
{{else}}
|
{{else}}
|
||||||
Not connected
|
Not connected
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -84,25 +106,50 @@ export default class UserMenuComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
{{#if this.osmAuth.isConnected}}
|
{{#if this.osmAuth.isConnected}}
|
||||||
{{this.osmAuth.userDisplayName}}
|
<strong>{{this.osmAuth.userDisplayName}}</strong>
|
||||||
{{else}}
|
{{else}}
|
||||||
Not connected
|
Not connected
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="account-item disabled">
|
<li class="account-item">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
<Icon @name="zap" @size={{18}} />
|
<Icon @name="zap" @size={{18}} />
|
||||||
<span>Nostr</span>
|
<span>Nostr</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-danger"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.disconnectNostr}}
|
||||||
|
>Disconnect</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn-text text-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.openNostrConnectModal}}
|
||||||
|
>Connect</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="account-status">
|
<div class="account-status">
|
||||||
Coming soon
|
{{#if this.nostrAuth.isConnected}}
|
||||||
|
<strong title={{this.nostrAuth.pubkey}}>
|
||||||
|
{{this.nostrData.userDisplayName}}
|
||||||
|
</strong>
|
||||||
|
{{else}}
|
||||||
|
Not connected
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.closeNostrConnectModal}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
@@ -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,178 @@ export default class SearchController extends Controller {
|
|||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
category = null;
|
category = null;
|
||||||
|
|
||||||
|
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||||
|
// Hide 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) {
|
||||||
|
let matchedPlace = null;
|
||||||
|
|
||||||
|
// 1. Exact Name Match
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default helper(function includes([collection, value]) {
|
||||||
|
if (!Array.isArray(collection)) return false;
|
||||||
|
return collection.includes(value);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="-1.66 0 740.82 740.82" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m630.06 107.66c-69.329-69.387-161.53-107.62-259.76-107.66-202.4 0-367.13 164.67-367.22 367.07-0.027 64.699 16.883 127.86 49.016 183.52l-52.095 190.23 194.67-51.047c53.634 29.244 114.02 44.656 175.48 44.682h0.151c202.38 0 367.13-164.69 367.21-367.09 0.039-98.088-38.121-190.32-107.45-259.71m-259.76 564.8h-0.125c-54.766-0.021-108.48-14.729-155.34-42.529l-11.146-6.613-115.52 30.293 30.834-112.59-7.258-11.543c-30.552-48.58-46.689-104.73-46.665-162.38 0.067-168.23 136.99-305.1 305.34-305.1 81.521 0.031 158.15 31.81 215.78 89.482s89.342 134.33 89.311 215.86c-0.07 168.24-136.99 305.12-305.21 305.12m167.42-228.51c-9.176-4.591-54.286-26.782-62.697-29.843-8.41-3.061-14.526-4.591-20.644 4.592-6.116 9.182-23.7 29.843-29.054 35.964-5.351 6.122-10.703 6.888-19.879 2.296-9.175-4.591-38.739-14.276-73.786-45.526-27.275-24.32-45.691-54.36-51.043-63.542-5.352-9.183-0.569-14.148 4.024-18.72 4.127-4.11 9.175-10.713 13.763-16.07 4.587-5.356 6.116-9.182 9.174-15.303 3.059-6.122 1.53-11.479-0.764-16.07s-20.643-49.739-28.29-68.104c-7.447-17.886-15.012-15.466-20.644-15.746-5.346-0.266-11.469-0.323-17.585-0.323-6.117 0-16.057 2.296-24.468 11.478-8.41 9.183-32.112 31.374-32.112 76.521s32.877 88.763 37.465 94.885c4.587 6.122 64.699 98.771 156.74 138.5 21.891 9.45 38.982 15.093 52.307 19.323 21.981 6.979 41.983 5.994 57.793 3.633 17.628-2.633 54.285-22.19 61.932-43.616 7.646-21.426 7.646-39.791 5.352-43.617-2.293-3.826-8.41-6.122-17.585-10.714" clip-rule="evenodd" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
const CACHE_NAME = 'nostr-image-cache-v1';
|
||||||
|
|
||||||
|
export default modifier((element, [url]) => {
|
||||||
|
let objectUrl = null;
|
||||||
|
|
||||||
|
async function loadImage() {
|
||||||
|
if (!url) {
|
||||||
|
element.src = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(url);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
const blob = await cachedResponse.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
element.src = objectUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, try to fetch it
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(url, {
|
||||||
|
mode: 'cors', // Required to read the blob for caching
|
||||||
|
credentials: 'omit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Clone the response before reading the blob because a response stream can only be read once
|
||||||
|
const cacheResponse = response.clone();
|
||||||
|
await cache.put(url, cacheResponse);
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
element.src = objectUrl;
|
||||||
|
} else {
|
||||||
|
// Fetch failed (e.g. 404), fallback to standard browser loading
|
||||||
|
element.src = url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// CORS errors or network failures will land here.
|
||||||
|
// Fallback to letting the browser handle it directly.
|
||||||
|
console.warn(
|
||||||
|
`Failed to cache image ${url}, falling back to standard src`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
element.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage();
|
||||||
|
|
||||||
|
// Cleanup: revoke the object URL when the element is destroyed or the URL changes
|
||||||
|
return () => {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier((element, [url]) => {
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// Remove classes when URL changes
|
||||||
|
element.classList.remove('loaded');
|
||||||
|
element.classList.remove('loaded-instant');
|
||||||
|
|
||||||
|
let observer;
|
||||||
|
|
||||||
|
const 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 = () => {
|
||||||
|
// Only apply the fade-in animation if it wasn't already loaded instantly
|
||||||
|
if (!element.classList.contains('loaded-instant')) {
|
||||||
|
element.classList.add('loaded');
|
||||||
|
}
|
||||||
|
hideBlurhash();
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('load', handleLoad);
|
||||||
|
|
||||||
|
const loadWhenVisible = (entries, obs) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Stop observing once we start loading
|
||||||
|
obs.unobserve(element);
|
||||||
|
|
||||||
|
// Check if the image is already in the browser cache
|
||||||
|
// Create an off-DOM image to reliably check cache status
|
||||||
|
// without waiting for the actual DOM element to load it
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
|
||||||
|
if (img.complete) {
|
||||||
|
// Already in browser cache, skip the animation
|
||||||
|
element.classList.add('loaded-instant');
|
||||||
|
hideBlurhash();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this image is inside a <picture> tag, we also need to swap <source> tags
|
||||||
|
const parent = element.parentElement;
|
||||||
|
if (parent && parent.tagName === 'PICTURE') {
|
||||||
|
const sources = parent.querySelectorAll('source');
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (source.dataset.srcset) {
|
||||||
|
source.srcset = source.dataset.srcset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap data-src to src to trigger the actual network fetch (or render from cache)
|
||||||
|
if (element.dataset.src) {
|
||||||
|
element.src = element.dataset.src;
|
||||||
|
} else {
|
||||||
|
// Fallback if data-src wasn't used but the modifier was called
|
||||||
|
element.src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup Intersection Observer to only load when the image enters the viewport
|
||||||
|
observer = new IntersectionObserver(loadWhenVisible, {
|
||||||
|
root: null, // Use the viewport as the root
|
||||||
|
rootMargin: '100px 100%', // Load one full viewport width ahead/behind
|
||||||
|
threshold: 0, // Trigger immediately when any part enters the expanded margin
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('load', handleLoad);
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
export default modifier((element, [text]) => {
|
||||||
|
if (text) {
|
||||||
|
QRCode.toCanvas(element, text, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to generate QR code', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+31
-8
@@ -9,25 +9,47 @@ export default class PlaceRoute extends Route {
|
|||||||
async model(params) {
|
async model(params) {
|
||||||
const id = params.place_id;
|
const id = params.place_id;
|
||||||
|
|
||||||
|
let type, osmId;
|
||||||
|
let isExplicitOsm = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
id.startsWith('osm:node:') ||
|
id.startsWith('osm:node:') ||
|
||||||
id.startsWith('osm:way:') ||
|
id.startsWith('osm:way:') ||
|
||||||
id.startsWith('osm:relation:')
|
id.startsWith('osm:relation:')
|
||||||
) {
|
) {
|
||||||
const [, type, osmId] = id.split(':');
|
isExplicitOsm = true;
|
||||||
|
[, type, osmId] = id.split(':');
|
||||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||||
return this.loadOsmPlace(osmId, type);
|
}
|
||||||
|
|
||||||
|
let backgroundFetchPromise = null;
|
||||||
|
if (isExplicitOsm) {
|
||||||
|
backgroundFetchPromise = this.loadOsmPlace(osmId, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.waitForSync();
|
await this.waitForSync();
|
||||||
|
|
||||||
let bookmark = this.storage.findPlaceById(id);
|
let lookupId = isExplicitOsm ? osmId : id;
|
||||||
|
let bookmark = this.storage.findPlaceById(lookupId);
|
||||||
|
|
||||||
|
// Ensure type matches if we are looking up by osmId
|
||||||
|
if (bookmark && isExplicitOsm && bookmark.osmType !== type) {
|
||||||
|
bookmark = null; // Type mismatch, not the same OSM object
|
||||||
|
}
|
||||||
|
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
console.debug('Found in bookmarks:', bookmark.title);
|
console.debug('Found in bookmarks:', bookmark.title);
|
||||||
return bookmark;
|
return bookmark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isExplicitOsm) {
|
||||||
|
console.debug(
|
||||||
|
`Not in bookmarks, using explicitly fetched OSM ${type}:`,
|
||||||
|
osmId
|
||||||
|
);
|
||||||
|
return await backgroundFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
console.warn('Not in bookmarks:', id);
|
console.warn('Not in bookmarks:', id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -74,6 +96,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)
|
||||||
@@ -119,14 +142,14 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialize(model) {
|
serialize(model) {
|
||||||
// If the model is a saved bookmark, use its ID
|
// If it's an OSM POI, use the explicit format first
|
||||||
if (model.id) {
|
|
||||||
return { place_id: model.id };
|
|
||||||
}
|
|
||||||
// If it's an OSM POI, use the explicit format
|
|
||||||
if (model.osmId && model.osmType) {
|
if (model.osmId && model.osmType) {
|
||||||
return { place_id: `osm:${model.osmType}:${model.osmId}` };
|
return { place_id: `osm:${model.osmType}:${model.osmId}` };
|
||||||
}
|
}
|
||||||
|
// If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID
|
||||||
|
if (model.id) {
|
||||||
|
return { place_id: model.id };
|
||||||
|
}
|
||||||
// Fallback
|
// Fallback
|
||||||
return { place_id: model.osmId };
|
return { place_id: model.osmId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import { sha256 } from '@noble/hashes/sha2.js';
|
||||||
|
|
||||||
|
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
|
||||||
|
|
||||||
|
function bufferToHex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlossomUrl(serverUrl, path) {
|
||||||
|
let url = serverUrl || DEFAULT_BLOSSOM_SERVER;
|
||||||
|
if (url.endsWith('/')) {
|
||||||
|
url = url.slice(0, -1);
|
||||||
|
}
|
||||||
|
return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BlossomService extends Service {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
get servers() {
|
||||||
|
const servers = this.nostrData.blossomServers;
|
||||||
|
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||||
|
|
||||||
|
if (!this.settings.nostrPhotoFallbackUploads) {
|
||||||
|
return [allServers[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return allServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAuthHeader(action, hash, serverUrl) {
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const serverHostname = new URL(serverUrl).hostname;
|
||||||
|
|
||||||
|
const authTemplate = {
|
||||||
|
kind: 24242,
|
||||||
|
created_at: now,
|
||||||
|
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
||||||
|
tags: [
|
||||||
|
['t', action],
|
||||||
|
['x', hash],
|
||||||
|
['expiration', String(now + 3600)],
|
||||||
|
['server', serverHostname],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const authEvent = await factory.sign(authTemplate);
|
||||||
|
const base64 = btoa(JSON.stringify(authEvent));
|
||||||
|
const base64url = base64
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
return `Nostr ${base64url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uploadToServer(file, hash, serverUrl, onProgress) {
|
||||||
|
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
|
||||||
|
|
||||||
|
if (onProgress) onProgress('signing');
|
||||||
|
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
|
||||||
|
|
||||||
|
if (onProgress) onProgress('uploading');
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'X-SHA-256': hash,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file, options = { sequential: false }) {
|
||||||
|
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
let hashBuffer;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
crypto.subtle &&
|
||||||
|
crypto.subtle.digest
|
||||||
|
) {
|
||||||
|
hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
} else {
|
||||||
|
hashBuffer = sha256(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadHash = bufferToHex(hashBuffer);
|
||||||
|
|
||||||
|
const servers = this.servers;
|
||||||
|
const mainServer = servers[0];
|
||||||
|
const fallbackServers = servers.slice(1);
|
||||||
|
|
||||||
|
const fallbackUrls = [];
|
||||||
|
let mainResult;
|
||||||
|
|
||||||
|
if (options.sequential) {
|
||||||
|
// Sequential upload logic
|
||||||
|
mainResult = await this._uploadToServer(
|
||||||
|
file,
|
||||||
|
payloadHash,
|
||||||
|
mainServer,
|
||||||
|
options.onProgress
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const serverUrl of fallbackServers) {
|
||||||
|
try {
|
||||||
|
const result = await this._uploadToServer(
|
||||||
|
file,
|
||||||
|
payloadHash,
|
||||||
|
serverUrl,
|
||||||
|
options.onProgress
|
||||||
|
);
|
||||||
|
fallbackUrls.push(result.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Fallback upload to ${serverUrl} failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Concurrent upload logic
|
||||||
|
const mainPromise = this._uploadToServer(
|
||||||
|
file,
|
||||||
|
payloadHash,
|
||||||
|
mainServer,
|
||||||
|
options.onProgress
|
||||||
|
);
|
||||||
|
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||||
|
this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main server MUST succeed
|
||||||
|
mainResult = await mainPromise;
|
||||||
|
|
||||||
|
// Fallback servers can fail, but we log the warnings
|
||||||
|
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||||
|
|
||||||
|
for (let i = 0; i < fallbackResults.length; i++) {
|
||||||
|
const result = fallbackResults[i];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
fallbackUrls.push(result.value.url);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Fallback upload to ${fallbackServers[i]} failed:`,
|
||||||
|
result.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: mainResult.url,
|
||||||
|
fallbackUrls,
|
||||||
|
hash: payloadHash,
|
||||||
|
type: file.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _deleteFromServer(hash, serverUrl) {
|
||||||
|
const deleteUrl = getBlossomUrl(serverUrl, hash);
|
||||||
|
const authHeader = await this._getAuthHeader('delete', hash, serverUrl);
|
||||||
|
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(deleteUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || response.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(hash) {
|
||||||
|
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const servers = this.servers;
|
||||||
|
const mainServer = servers[0];
|
||||||
|
const fallbackServers = servers.slice(1);
|
||||||
|
|
||||||
|
const mainPromise = this._deleteFromServer(hash, mainServer);
|
||||||
|
const fallbackPromises = fallbackServers.map((serverUrl) =>
|
||||||
|
this._deleteFromServer(hash, serverUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main server MUST succeed
|
||||||
|
await mainPromise;
|
||||||
|
|
||||||
|
// Fallback servers can fail, log warnings
|
||||||
|
const fallbackResults = await Promise.allSettled(fallbackPromises);
|
||||||
|
for (let i = 0; i < fallbackResults.length; i++) {
|
||||||
|
const result = fallbackResults[i];
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.warn(
|
||||||
|
`Fallback delete from ${fallbackServers[i]} failed:`,
|
||||||
|
result.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
// We use the special Vite query parameter to load this as a web worker
|
||||||
|
import Worker from '../workers/image-processor?worker';
|
||||||
|
|
||||||
|
export default class ImageProcessorService extends Service {
|
||||||
|
_worker = null;
|
||||||
|
_callbacks = new Map();
|
||||||
|
_msgId = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this._initWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initWorker() {
|
||||||
|
if (!this._worker && typeof Worker !== 'undefined') {
|
||||||
|
try {
|
||||||
|
this._worker = new Worker();
|
||||||
|
this._worker.onmessage = this._handleMessage.bind(this);
|
||||||
|
this._worker.onerror = this._handleError.bind(this);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to initialize image-processor worker:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMessage(e) {
|
||||||
|
const { id, success, blob, dim, blurhash, error } = e.data;
|
||||||
|
const resolver = this._callbacks.get(id);
|
||||||
|
|
||||||
|
if (resolver) {
|
||||||
|
this._callbacks.delete(id);
|
||||||
|
if (success) {
|
||||||
|
resolver.resolve({ blob, dim, blurhash });
|
||||||
|
} else {
|
||||||
|
resolver.reject(new Error(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleError(error) {
|
||||||
|
console.error('Image Processor Worker Error:', error);
|
||||||
|
// Reject all pending jobs
|
||||||
|
for (const [, resolver] of this._callbacks.entries()) {
|
||||||
|
resolver.reject(new Error('Worker crashed'));
|
||||||
|
}
|
||||||
|
this._callbacks.clear();
|
||||||
|
// Restart the worker for future jobs
|
||||||
|
this._worker.terminate();
|
||||||
|
this._worker = null;
|
||||||
|
this._initWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
_getImageDimensions(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const dimensions = { width: img.width, height: img.height };
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(dimensions);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Could not read image dimensions'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(file, maxDimension, quality, computeBlurhash = false) {
|
||||||
|
if (!this._worker) {
|
||||||
|
// Fallback if worker initialization failed (e.g. incredibly old browsers)
|
||||||
|
throw new Error('Image processor worker is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get dimensions safely on the main thread
|
||||||
|
const { width: origWidth, height: origHeight } =
|
||||||
|
await this._getImageDimensions(file);
|
||||||
|
|
||||||
|
// 2. Calculate aspect-ratio preserving dimensions
|
||||||
|
let targetWidth = origWidth;
|
||||||
|
let targetHeight = origHeight;
|
||||||
|
|
||||||
|
if (origWidth > origHeight) {
|
||||||
|
if (origWidth > maxDimension) {
|
||||||
|
targetHeight = Math.round(origHeight * (maxDimension / origWidth));
|
||||||
|
targetWidth = maxDimension;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (origHeight > maxDimension) {
|
||||||
|
targetWidth = Math.round(origWidth * (maxDimension / origHeight));
|
||||||
|
targetHeight = maxDimension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send to worker for processing
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++this._msgId;
|
||||||
|
this._callbacks.set(id, { resolve, reject });
|
||||||
|
|
||||||
|
this._worker.postMessage({
|
||||||
|
type: 'PROCESS_IMAGE',
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
quality,
|
||||||
|
computeBlurhash,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to process image: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
if (this._worker) {
|
||||||
|
this._worker.terminate();
|
||||||
|
this._worker = null;
|
||||||
|
}
|
||||||
|
this._callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -1,7 +1,9 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
export default class MapUiService extends Service {
|
export default class MapUiService extends Service {
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
@tracked selectedPlace = null;
|
@tracked selectedPlace = null;
|
||||||
@tracked isSearching = false;
|
@tracked isSearching = false;
|
||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@@ -9,22 +11,34 @@ export default class MapUiService extends Service {
|
|||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@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;
|
||||||
|
|
||||||
|
showSidebar() {
|
||||||
|
this.isSidebarVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSidebar() {
|
||||||
|
this.isSidebarVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
this.selectionOptions = options;
|
this.selectionOptions = options;
|
||||||
|
this.nostrData.loadPhotosForPlace(place);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.selectedPlace = null;
|
this.selectedPlace = null;
|
||||||
this.selectionOptions = {};
|
this.selectionOptions = {};
|
||||||
this.preventNextZoom = false;
|
this.preventNextZoom = false;
|
||||||
|
this.nostrData.loadPhotosForPlace(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchResults(results) {
|
setSearchResults(results) {
|
||||||
@@ -68,6 +82,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import {
|
||||||
|
ExtensionSigner,
|
||||||
|
NostrConnectSigner,
|
||||||
|
PrivateKeySigner,
|
||||||
|
} from 'applesauce-signers';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'marco:nostr_pubkey';
|
||||||
|
const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect'
|
||||||
|
const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
||||||
|
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||||
|
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||||
|
|
||||||
|
import { isMobile } from '../utils/device';
|
||||||
|
|
||||||
|
export default class NostrAuthService extends Service {
|
||||||
|
@service nostrRelay;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
|
@tracked pubkey = null;
|
||||||
|
@tracked signerType = null; // 'extension' or 'connect'
|
||||||
|
|
||||||
|
// Track NostrConnect state for the UI
|
||||||
|
@tracked connectStatus = null; // null | 'waiting' | 'connected'
|
||||||
|
@tracked connectUri = null; // For displaying a QR code if needed
|
||||||
|
|
||||||
|
_signerInstance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
// Enable debug logging for applesauce packages
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.debug = 'applesauce:*';
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const type = localStorage.getItem(STORAGE_KEY_TYPE);
|
||||||
|
if (saved) {
|
||||||
|
this.pubkey = saved;
|
||||||
|
this.signerType = type || 'extension';
|
||||||
|
this._verifyPubkey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _verifyPubkey() {
|
||||||
|
if (this.signerType === 'extension') {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
this.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const signer = new ExtensionSigner();
|
||||||
|
const extensionPubkey = await signer.getPublicKey();
|
||||||
|
if (extensionPubkey !== this.pubkey) {
|
||||||
|
this.pubkey = extensionPubkey;
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
}
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to verify extension nostr pubkey, logging out', e);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
} else if (this.signerType === 'connect') {
|
||||||
|
try {
|
||||||
|
await this._initConnectSigner();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to verify connect nostr pubkey, logging out', e);
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile() {
|
||||||
|
return isMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return (
|
||||||
|
!!this.pubkey &&
|
||||||
|
(this.signerType === 'extension'
|
||||||
|
? typeof window.nostr !== 'undefined'
|
||||||
|
: true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
if (this._signerInstance) return this._signerInstance;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.signerType === 'extension' &&
|
||||||
|
typeof window.nostr !== 'undefined'
|
||||||
|
) {
|
||||||
|
return new ExtensionSigner();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.signerType === 'connect') {
|
||||||
|
// Must be initialized async due to the connect handshakes
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithExtension() {
|
||||||
|
if (typeof window.nostr === 'undefined') {
|
||||||
|
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._signerInstance = new ExtensionSigner();
|
||||||
|
this.pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
this.signerType = 'extension';
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get public key from extension:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getLocalSigner() {
|
||||||
|
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||||
|
let localSigner;
|
||||||
|
if (localKeyHex) {
|
||||||
|
localSigner = PrivateKeySigner.fromKey(localKeyHex);
|
||||||
|
} else {
|
||||||
|
localSigner = new PrivateKeySigner();
|
||||||
|
// Store the raw Uint8Array as hex string
|
||||||
|
localKeyHex = Array.from(localSigner.key)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
|
||||||
|
}
|
||||||
|
return localSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithApp() {
|
||||||
|
this.connectStatus = 'waiting';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localSigner = this._getLocalSigner();
|
||||||
|
|
||||||
|
// We use a specific relay for the connection handshake.
|
||||||
|
const relay = DEFAULT_CONNECT_RELAY;
|
||||||
|
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||||
|
|
||||||
|
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||||
|
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||||
|
|
||||||
|
this._signerInstance = new NostrConnectSigner({
|
||||||
|
pool: this.nostrRelay.pool,
|
||||||
|
relays: [relay],
|
||||||
|
signer: localSigner,
|
||||||
|
onAuth: async (url) => {
|
||||||
|
// NIP-46 auth callback. Normally the signer app does this natively via notification.
|
||||||
|
// But if it requires an explicit browser window:
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the uri for display (e.g., to redirect via intent)
|
||||||
|
this.connectUri = this._signerInstance.getNostrConnectURI({
|
||||||
|
name: 'Marco',
|
||||||
|
url: window.location.origin,
|
||||||
|
description: 'An unhosted maps application.',
|
||||||
|
icons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger the deep link intent immediately for the user if on mobile
|
||||||
|
if (this.isMobile) {
|
||||||
|
console.debug('Mobile detected, triggering deep link intent.');
|
||||||
|
window.location.href = this.connectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listening to the relay
|
||||||
|
console.debug('Opening signer connection to relay...');
|
||||||
|
await this._signerInstance.open();
|
||||||
|
console.debug('Signer connection opened successfully.');
|
||||||
|
|
||||||
|
// Wait for the remote signer to reply with their pubkey
|
||||||
|
console.debug('Waiting for remote signer to ack via relay...');
|
||||||
|
try {
|
||||||
|
await this._signerInstance.waitForSigner();
|
||||||
|
console.debug('Remote signer ack received!');
|
||||||
|
} catch (waitErr) {
|
||||||
|
console.error('Error while waiting for remote signer ack:', waitErr);
|
||||||
|
throw waitErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once connected, get the actual user pubkey
|
||||||
|
this.pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
this.signerType = 'connect';
|
||||||
|
this.connectStatus = 'connected';
|
||||||
|
|
||||||
|
// Save connection state
|
||||||
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
|
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
|
||||||
|
this._signerInstance.remote
|
||||||
|
);
|
||||||
|
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
|
||||||
|
return this.pubkey;
|
||||||
|
} catch (error) {
|
||||||
|
this.connectStatus = null;
|
||||||
|
console.error('Failed to connect via Nostr Connect:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _initConnectSigner() {
|
||||||
|
const remotePubkey = localStorage.getItem(
|
||||||
|
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
|
||||||
|
);
|
||||||
|
const relay =
|
||||||
|
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY;
|
||||||
|
|
||||||
|
if (!remotePubkey) {
|
||||||
|
throw new Error('Missing Nostr Connect remote pubkey.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSigner = this._getLocalSigner();
|
||||||
|
|
||||||
|
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
||||||
|
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
||||||
|
|
||||||
|
this._signerInstance = new NostrConnectSigner({
|
||||||
|
pool: this.nostrRelay.pool,
|
||||||
|
relays: [relay],
|
||||||
|
signer: localSigner,
|
||||||
|
remote: remotePubkey,
|
||||||
|
onAuth: async (url) => {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this._signerInstance.open();
|
||||||
|
// Validate we can still get the pubkey from the remote signer
|
||||||
|
const pubkey = await this._signerInstance.getPublicKey();
|
||||||
|
if (pubkey !== this.pubkey) {
|
||||||
|
throw new Error('Remote signer pubkey mismatch');
|
||||||
|
}
|
||||||
|
this.nostrData.loadProfile(this.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event) {
|
||||||
|
if (!this.signer) {
|
||||||
|
throw new Error(
|
||||||
|
'Not connected or extension missing. Please connect Nostr again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.signer.signEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
this.pubkey = null;
|
||||||
|
this.nostrData?.loadProfile(null);
|
||||||
|
this.signerType = null;
|
||||||
|
this.connectStatus = null;
|
||||||
|
this.connectUri = null;
|
||||||
|
if (
|
||||||
|
this._signerInstance &&
|
||||||
|
typeof this._signerInstance.close === 'function'
|
||||||
|
) {
|
||||||
|
await this._signerInstance.close();
|
||||||
|
}
|
||||||
|
this._signerInstance = null;
|
||||||
|
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_TYPE);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { EventStore } from 'applesauce-core/event-store';
|
||||||
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||||
|
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||||
|
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||||
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
|
import { normalizeRelayUrl } from '../utils/nostr';
|
||||||
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||||
|
|
||||||
|
const DIRECTORY_RELAYS = [
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||||
|
const DEFAULT_WRITE_RELAYS = [];
|
||||||
|
|
||||||
|
export default class NostrDataService extends Service {
|
||||||
|
@service nostrRelay;
|
||||||
|
@service nostrAuth;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
store = new EventStore();
|
||||||
|
|
||||||
|
@tracked profile = null;
|
||||||
|
@tracked mailboxes = null;
|
||||||
|
@tracked blossomServers = [];
|
||||||
|
@tracked placePhotos = [];
|
||||||
|
|
||||||
|
_profileSub = null;
|
||||||
|
_mailboxesSub = null;
|
||||||
|
_blossomSub = null;
|
||||||
|
_photosSub = null;
|
||||||
|
|
||||||
|
_requestSub = null;
|
||||||
|
_cachePromise = null;
|
||||||
|
loadedGeohashPrefixes = new Set();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
// Initialize the IndexedDB cache
|
||||||
|
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||||
|
this.cache = new NostrIDB(db, {
|
||||||
|
cacheIndexes: 1000,
|
||||||
|
maxEvents: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.cache.start();
|
||||||
|
|
||||||
|
// Automatically persist new events to the cache
|
||||||
|
this._stopPersisting = persistEventsToCache(
|
||||||
|
this.store,
|
||||||
|
async (events) => {
|
||||||
|
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||||
|
const toCache = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.kind === 0 ||
|
||||||
|
e.kind === 5 ||
|
||||||
|
e.kind === 10002 ||
|
||||||
|
e.kind === 10063 ||
|
||||||
|
e.kind === 360
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toCache.length > 0) {
|
||||||
|
await Promise.all(toCache.map((event) => this.cache.add(event)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
batchTime: 1000, // Batch writes every 1 second
|
||||||
|
maxBatchSize: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feed events from the relay pool into the event store
|
||||||
|
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||||
|
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||||
|
// which returns an Observable<NostrEvent>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultReadRelays() {
|
||||||
|
const mailboxes = (this.mailboxes?.inboxes || [])
|
||||||
|
.map(normalizeRelayUrl)
|
||||||
|
.filter(Boolean);
|
||||||
|
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||||
|
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultWriteRelays() {
|
||||||
|
const mailboxes = (this.mailboxes?.outboxes || [])
|
||||||
|
.map(normalizeRelayUrl)
|
||||||
|
.filter(Boolean);
|
||||||
|
const defaults =
|
||||||
|
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
|
||||||
|
return Array.from(new Set([...defaults, ...mailboxes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeReadRelays() {
|
||||||
|
if (this.settings.nostrReadRelays) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.defaultReadRelays;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWriteRelays() {
|
||||||
|
if (this.settings.nostrWriteRelays) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.defaultWriteRelays;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPlacesInBounds(bbox) {
|
||||||
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
|
|
||||||
|
const missingPrefixes = requiredPrefixes.filter(
|
||||||
|
(p) => !this.loadedGeohashPrefixes.has(p)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingPrefixes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
'[nostr-data] Loading place photos for prefixes:',
|
||||||
|
missingPrefixes
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._cachePromise;
|
||||||
|
|
||||||
|
const cachedEvents = await this.cache.query([
|
||||||
|
{
|
||||||
|
kinds: [360],
|
||||||
|
'#g': missingPrefixes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'[nostr-data] Failed to read photos from local Nostr IDB cache',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire network request for new prefixes
|
||||||
|
this.nostrRelay.pool
|
||||||
|
.request(this.activeReadRelays, [
|
||||||
|
{
|
||||||
|
kinds: [360],
|
||||||
|
'#g': missingPrefixes,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(
|
||||||
|
'[nostr-data] Error fetching place photos by geohash:',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const p of missingPrefixes) {
|
||||||
|
this.loadedGeohashPrefixes.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPhotosForPlace(place) {
|
||||||
|
if (this._photosSub) {
|
||||||
|
this._photosSub.unsubscribe();
|
||||||
|
this._photosSub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.placePhotos = [];
|
||||||
|
|
||||||
|
if (!place || !place.osmId || !place.osmType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
|
||||||
|
// Setup reactive store query
|
||||||
|
this._photosSub = this.store
|
||||||
|
.timeline([
|
||||||
|
{
|
||||||
|
kinds: [360],
|
||||||
|
'#i': [entityId],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.subscribe((events) => {
|
||||||
|
this.placePhotos = events;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._cachePromise;
|
||||||
|
|
||||||
|
const cachedEvents = await this.cache.query([
|
||||||
|
{
|
||||||
|
kinds: [360, 5],
|
||||||
|
'#i': [entityId],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'[nostr-data] Failed to read photos for place from local Nostr IDB cache',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire network request specifically for this place
|
||||||
|
this.nostrRelay.pool
|
||||||
|
.request(this.activeReadRelays, [
|
||||||
|
{
|
||||||
|
kinds: [360, 5],
|
||||||
|
'#i': [entityId],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(
|
||||||
|
'[nostr-data] Error fetching place photos for place:',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadProfile(pubkey) {
|
||||||
|
if (!pubkey) return;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.profile = null;
|
||||||
|
this.mailboxes = null;
|
||||||
|
this.blossomServers = [];
|
||||||
|
|
||||||
|
this._cleanupSubscriptions();
|
||||||
|
|
||||||
|
// Setup models to track state reactively FIRST
|
||||||
|
// This way, if cached events populate the store, the UI updates instantly.
|
||||||
|
this._profileSub = this.store
|
||||||
|
.model(ProfileModel, pubkey)
|
||||||
|
.subscribe((profileContent) => {
|
||||||
|
this.profile = profileContent;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._mailboxesSub = this.store
|
||||||
|
.model(MailboxesModel, pubkey)
|
||||||
|
.subscribe((mailboxesData) => {
|
||||||
|
this.mailboxes = mailboxesData;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._blossomSub = this.store
|
||||||
|
.replaceable(10063, pubkey)
|
||||||
|
.subscribe((event) => {
|
||||||
|
if (event && event.tags) {
|
||||||
|
this.blossomServers = event.tags
|
||||||
|
.filter((t) => t[0] === 'server' && t[1])
|
||||||
|
.map((t) => t[1]);
|
||||||
|
} else {
|
||||||
|
this.blossomServers = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Await cache initialization and populate the EventStore with local data
|
||||||
|
try {
|
||||||
|
await this._cachePromise;
|
||||||
|
|
||||||
|
const cachedEvents = await this.cache.query([
|
||||||
|
{
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [0, 10002, 10063],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read from local Nostr IDB cache', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Request new events from the network in the background and dump them into the store
|
||||||
|
const profileRelays = Array.from(
|
||||||
|
new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays])
|
||||||
|
);
|
||||||
|
this._requestSub = this.nostrRelay.pool
|
||||||
|
.request(profileRelays, [
|
||||||
|
{
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [0, 10002, 10063],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error fetching profile events:', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get userDisplayName() {
|
||||||
|
if (this.profile) {
|
||||||
|
if (this.profile.nip05) {
|
||||||
|
return this.profile.nip05;
|
||||||
|
}
|
||||||
|
if (this.profile.displayName || this.profile.display_name) {
|
||||||
|
return this.profile.displayName || this.profile.display_name;
|
||||||
|
}
|
||||||
|
if (this.profile.name) {
|
||||||
|
return this.profile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to npub
|
||||||
|
if (this.nostrAuth.pubkey) {
|
||||||
|
try {
|
||||||
|
const npub = npubEncode(this.nostrAuth.pubkey);
|
||||||
|
return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
|
||||||
|
} catch {
|
||||||
|
return this.nostrAuth.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Not connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache() {
|
||||||
|
await this._cachePromise;
|
||||||
|
if (this.cache) {
|
||||||
|
await this.cache.deleteAllEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupSubscriptions() {
|
||||||
|
if (this._requestSub) {
|
||||||
|
this._requestSub.unsubscribe();
|
||||||
|
this._requestSub = null;
|
||||||
|
}
|
||||||
|
if (this._profileSub) {
|
||||||
|
this._profileSub.unsubscribe();
|
||||||
|
this._profileSub = null;
|
||||||
|
}
|
||||||
|
if (this._mailboxesSub) {
|
||||||
|
this._mailboxesSub.unsubscribe();
|
||||||
|
this._mailboxesSub = null;
|
||||||
|
}
|
||||||
|
if (this._blossomSub) {
|
||||||
|
this._blossomSub.unsubscribe();
|
||||||
|
this._blossomSub = null;
|
||||||
|
}
|
||||||
|
if (this._photosSub) {
|
||||||
|
this._photosSub.unsubscribe();
|
||||||
|
this._photosSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
this._cleanupSubscriptions();
|
||||||
|
|
||||||
|
if (this._stopPersisting) {
|
||||||
|
this._stopPersisting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
this.cache.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
|
export default class NostrRelayService extends Service {
|
||||||
|
pool = new RelayPool({ enablePing: true });
|
||||||
|
|
||||||
|
async publish(relays, event) {
|
||||||
|
if (!relays || relays.length === 0) {
|
||||||
|
throw new Error('No relays provided to publish the event.');
|
||||||
|
}
|
||||||
|
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||||
|
// and automatically handles reconnecting and retrying.
|
||||||
|
const responses = await this.pool.publish(relays, event);
|
||||||
|
|
||||||
|
// Check if at least one relay accepted the event
|
||||||
|
const success = responses.some((res) => res.ok);
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,15 @@ class MarcoOsmAuthStorage {
|
|||||||
localStorage.setItem('marco:osm_auth_state', serializedState);
|
localStorage.setItem('marco:osm_auth_state', serializedState);
|
||||||
}
|
}
|
||||||
loadState() {
|
loadState() {
|
||||||
return localStorage.getItem('marco:osm_auth_state');
|
const state = localStorage.getItem('marco:osm_auth_state');
|
||||||
|
if (!state) return false;
|
||||||
|
try {
|
||||||
|
JSON.parse(state);
|
||||||
|
return state;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse OSM auth state', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +53,11 @@ export default class OsmAuthService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restoreSession() {
|
async restoreSession() {
|
||||||
|
try {
|
||||||
|
await this.oauthClient.ready;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('oauthClient.ready failed', e);
|
||||||
|
}
|
||||||
const isAuthorized = await this.oauthClient.isAuthorized();
|
const isAuthorized = await this.oauthClient.isAuthorized();
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
|
|||||||
+26
-1
@@ -8,6 +8,7 @@ export default class OsmService extends Service {
|
|||||||
controller = null;
|
controller = null;
|
||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
|
cachedPlaces = new Map();
|
||||||
|
|
||||||
cancelAll() {
|
cancelAll() {
|
||||||
if (this.controller) {
|
if (this.controller) {
|
||||||
@@ -232,6 +233,13 @@ out center;
|
|||||||
async fetchOsmObject(osmId, osmType) {
|
async fetchOsmObject(osmId, osmType) {
|
||||||
if (!osmId || !osmType) return null;
|
if (!osmId || !osmType) return null;
|
||||||
|
|
||||||
|
const cacheKey = `${osmType}:${osmId}`;
|
||||||
|
const cached = this.cachedPlaces.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||||
|
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
if (osmType === 'node') {
|
if (osmType === 'node') {
|
||||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||||
@@ -253,8 +261,25 @@ out center;
|
|||||||
}
|
}
|
||||||
throw new Error(`OSM API request failed: ${res.status}`);
|
throw new Error(`OSM API request failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
const normalizedData = this.normalizeOsmApiData(
|
||||||
|
data.elements,
|
||||||
|
osmId,
|
||||||
|
osmType
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cachedPlaces.set(cacheKey, {
|
||||||
|
data: normalizedData,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup cache entry automatically after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.cachedPlaces.delete(cacheKey);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return normalizedData;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch OSM object:', e);
|
console.error('Failed to fetch OSM object:', e);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+92
-38
@@ -1,11 +1,28 @@
|
|||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
overpassApi: 'https://overpass-api.de/api/interpreter',
|
||||||
|
mapKinetic: true,
|
||||||
|
photonApi: 'https://photon.komoot.io/api/',
|
||||||
|
showQuickSearchButtons: true,
|
||||||
|
nostrPhotoFallbackUploads: false,
|
||||||
|
nostrReadRelays: null,
|
||||||
|
nostrWriteRelays: null,
|
||||||
|
experimentalEnablePhotoDeletion: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
@tracked overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||||
@tracked mapKinetic = true;
|
@tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic;
|
||||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
@tracked photonApi = DEFAULT_SETTINGS.photonApi;
|
||||||
@tracked showQuickSearchButtons = true;
|
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||||
|
@tracked nostrPhotoFallbackUploads =
|
||||||
|
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||||
|
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||||
|
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||||
|
@tracked experimentalEnablePhotoDeletion =
|
||||||
|
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -39,49 +56,86 @@ export default class SettingsService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSettings() {
|
loadSettings() {
|
||||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
let settings = {};
|
||||||
if (savedApi) {
|
const savedSettings = localStorage.getItem('marco:settings');
|
||||||
// Check if saved API is still in the allowed list
|
|
||||||
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
if (savedSettings) {
|
||||||
if (isValid) {
|
try {
|
||||||
this.overpassApi = savedApi;
|
settings = JSON.parse(savedSettings);
|
||||||
} else {
|
} catch (e) {
|
||||||
// If not valid, revert to default
|
console.error('Failed to parse settings from localStorage', e);
|
||||||
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
|
||||||
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Migration from old individual keys
|
||||||
|
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||||
|
if (savedApi) settings.overpassApi = savedApi;
|
||||||
|
|
||||||
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
|
if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true';
|
||||||
|
|
||||||
|
const savedShowQuickSearch = localStorage.getItem(
|
||||||
|
'marco:show-quick-search'
|
||||||
|
);
|
||||||
|
if (savedShowQuickSearch !== null) {
|
||||||
|
settings.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedNostrPhotoFallbackUploads = localStorage.getItem(
|
||||||
|
'marco:nostr-photo-fallback-uploads'
|
||||||
|
);
|
||||||
|
if (savedNostrPhotoFallbackUploads !== null) {
|
||||||
|
settings.nostrPhotoFallbackUploads =
|
||||||
|
savedNostrPhotoFallbackUploads === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPhotonApi = localStorage.getItem('marco:photon-api');
|
||||||
|
if (savedPhotonApi) settings.photonApi = savedPhotonApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
// Merge with defaults
|
||||||
if (savedKinetic !== null) {
|
const finalSettings = { ...DEFAULT_SETTINGS, ...settings };
|
||||||
this.mapKinetic = savedKinetic === 'true';
|
|
||||||
}
|
|
||||||
// Default is true (initialized in class field)
|
|
||||||
|
|
||||||
const savedShowQuickSearch = localStorage.getItem(
|
// Validate overpass API
|
||||||
'marco:show-quick-search'
|
const isValid = this.overpassApis.some(
|
||||||
|
(api) => api.url === finalSettings.overpassApi
|
||||||
);
|
);
|
||||||
if (savedShowQuickSearch !== null) {
|
if (!isValid) {
|
||||||
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply to tracked properties
|
||||||
|
this.overpassApi = finalSettings.overpassApi;
|
||||||
|
this.mapKinetic = finalSettings.mapKinetic;
|
||||||
|
this.photonApi = finalSettings.photonApi;
|
||||||
|
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
||||||
|
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||||
|
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||||
|
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||||
|
this.experimentalEnablePhotoDeletion =
|
||||||
|
finalSettings.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
|
// Save to ensure migrated settings are stored in the new format
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverpassApi(url) {
|
saveSettings() {
|
||||||
this.overpassApi = url;
|
const settings = {
|
||||||
localStorage.setItem('marco:overpass-api', url);
|
overpassApi: this.overpassApi,
|
||||||
|
mapKinetic: this.mapKinetic,
|
||||||
|
photonApi: this.photonApi,
|
||||||
|
showQuickSearchButtons: this.showQuickSearchButtons,
|
||||||
|
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||||
|
nostrReadRelays: this.nostrReadRelays,
|
||||||
|
nostrWriteRelays: this.nostrWriteRelays,
|
||||||
|
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||||
|
};
|
||||||
|
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMapKinetic(enabled) {
|
update(key, value) {
|
||||||
this.mapKinetic = enabled;
|
if (key in DEFAULT_SETTINGS) {
|
||||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
this[key] = value;
|
||||||
}
|
this.saveSettings();
|
||||||
|
}
|
||||||
updateShowQuickSearchButtons(enabled) {
|
|
||||||
this.showQuickSearchButtons = enabled;
|
|
||||||
localStorage.setItem('marco:show-quick-search', String(enabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePhotonApi(url) {
|
|
||||||
this.photonApi = url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+45
-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],
|
||||||
});
|
});
|
||||||
@@ -46,9 +50,23 @@ export default class StorageService extends Service {
|
|||||||
// console.debug('[rs] client ready');
|
// console.debug('[rs] client ready');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.rs.on('error', (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
console.info('[rs] Error —', `${error.name}: ${error.message}`);
|
||||||
|
if (error.name === 'Unauthorized') {
|
||||||
|
this.showConnectWidget();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,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', () => {
|
||||||
@@ -85,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;
|
||||||
|
|
||||||
@@ -445,7 +489,7 @@ export default class StorageService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connect() {
|
showConnectWidget() {
|
||||||
this.isWidgetOpen = true;
|
this.isWidgetOpen = true;
|
||||||
|
|
||||||
// Check if widget is already attached
|
// Check if widget is already attached
|
||||||
|
|||||||
+762
-8
@@ -8,12 +8,16 @@
|
|||||||
--link-color-visited: #6a4fbf;
|
--link-color-visited: #6a4fbf;
|
||||||
--marker-color-primary: #ea4335;
|
--marker-color-primary: #ea4335;
|
||||||
--marker-color-dark: #b31412;
|
--marker-color-dark: #b31412;
|
||||||
|
--danger-color: var(--marker-color-primary);
|
||||||
|
--danger-color-dark: var(--marker-color-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Noto Sans', sans-serif;
|
font-family: 'Noto Sans', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: normal;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +182,9 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-placeholder {
|
.user-avatar-placeholder {
|
||||||
@@ -187,7 +195,156 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
background-color: rgb(255 255 255 / 2%);
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.is-dragging {
|
||||||
|
border-color: #61afef;
|
||||||
|
background-color: rgb(97 175 239 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #898989;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e262e;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .overlay,
|
||||||
|
.photo-upload-item .btn-remove-photo {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 60%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-text {
|
||||||
|
color: white;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .error-overlay {
|
||||||
|
background: rgb(224 108 117 / 80%);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .btn-remove-photo {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgb(0 0 0 / 70%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-upload-item .btn-remove-photo:hover {
|
||||||
|
background: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-publish {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Menu Popover */
|
/* User Menu Popover */
|
||||||
@@ -246,10 +403,16 @@ body {
|
|||||||
|
|
||||||
.account-status {
|
.account-status {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: bold;
|
|
||||||
color: #898989;
|
color: #898989;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
margin-left: calc(18px + 0.75rem);
|
margin-left: calc(18px + 0.75rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status strong {
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-item.disabled {
|
.account-item.disabled {
|
||||||
@@ -261,7 +424,6 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.btn-text {
|
||||||
@@ -323,6 +485,10 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header.no-border {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header h2 {
|
.sidebar-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -430,6 +596,67 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .details-content.form-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-relay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid var(--danger-color);
|
||||||
|
color: var(--danger-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-relay svg {
|
||||||
|
stroke: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-relay:hover,
|
||||||
|
.btn-remove-relay:active {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-relay-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link.reset-relays {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes details-slide-down {
|
@keyframes details-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -458,7 +685,7 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
@@ -502,6 +729,11 @@ select.form-control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-section .form-group {
|
.settings-section .form-group {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section .form-group:first-of-type {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,12 +787,19 @@ select.form-control {
|
|||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a,
|
||||||
|
.meta-info .btn-link {
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.meta-info a:hover,
|
||||||
|
.meta-info .btn-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,6 +893,164 @@ abbr[title] {
|
|||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-carousel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline {
|
||||||
|
margin: -1rem -1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel-track {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel-track::-webkit-scrollbar {
|
||||||
|
display: none; /* Safari and Chrome */
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-slide {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo-blur {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
z-index: 1; /* Stay above blurhash */
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo.landscape {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo.portrait {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-header-photo.loaded-instant {
|
||||||
|
opacity: 1;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
background-color 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel:hover .carousel-nav-btn:not(.disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn:not(.disabled):hover {
|
||||||
|
background: rgb(0 0 0 / 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn.disabled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn.prev {
|
||||||
|
left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-btn.next {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-carousel.inline .photo-carousel-track {
|
||||||
|
scroll-snap-type: none;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .carousel-slide {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 100px;
|
||||||
|
width: auto;
|
||||||
|
scroll-snap-align: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .carousel-slide.landscape {
|
||||||
|
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .carousel-slide.portrait {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .carousel-placeholder {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .place-header-photo.landscape,
|
||||||
|
.photo-carousel.inline .place-header-photo.portrait {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.inline .carousel-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.place-details h3 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -687,6 +1084,7 @@ abbr[title] {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@@ -830,6 +1228,7 @@ abbr[title] {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
margin: -6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo-icon svg {
|
.app-logo-icon svg {
|
||||||
@@ -974,10 +1373,10 @@ span.icon {
|
|||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||||
|
|
||||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
/* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: 50%; /* Reset desktop shift */
|
left: 50%; /* Reset desktop shift */
|
||||||
top: 25%;
|
top: calc(25% + 30px); /* 30px approx half header height */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1369,3 +1768,358 @@ button.create-place {
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nostr Connect */
|
||||||
|
.nostr-connect-modal h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-connect-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-connect-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nostr-connect-status {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container canvas {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white; /* Ensure good contrast for scanning */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 450px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #898989;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-btn.disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-photo-upload h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions {
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-title {
|
||||||
|
color: #898989;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip.is-selected {
|
||||||
|
background: rgb(255 204 51 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-group img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Gallery */
|
||||||
|
.photo-gallery-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 90%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .photo-gallery-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
z-index: 10;
|
||||||
|
color: white;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-gallery-overlay .close-btn {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .main-photo-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-gallery-overlay .main-photo-container {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery-overlay .main-photo-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Carousel: Gallery Main Variant */
|
||||||
|
.photo-carousel.gallery-main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .photo-carousel-track {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .carousel-slide {
|
||||||
|
height: 100%;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-main .place-header-photo-blur,
|
||||||
|
.photo-carousel.gallery-main .place-header-photo.landscape,
|
||||||
|
.photo-carousel.gallery-main .place-header-photo.portrait {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.photo-carousel.gallery-main .carousel-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Carousel: Gallery Thumbnails Variant */
|
||||||
|
.photo-carousel.gallery-thumbnails {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .photo-carousel-track {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
gap: 4px;
|
||||||
|
scroll-snap-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
scroll-snap-align: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
|
||||||
|
aspect-ratio: var(--slide-ratio, 16 / 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-slide.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
|
||||||
|
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
|
||||||
|
object-fit: cover;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Menu Component */
|
||||||
|
.dropdown-menu-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
z-index: 3001;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions button in photo gallery */
|
||||||
|
.photo-gallery-overlay .actions-btn-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component {
|
|||||||
@tracked isAppMenuOpen = false;
|
@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 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.
|
||||||
return (
|
return (
|
||||||
this.router.currentRouteName === 'place' ||
|
this.mapUi.isSidebarVisible &&
|
||||||
this.router.currentRouteName === 'place.new' ||
|
(this.router.currentRouteName === 'place' ||
|
||||||
this.router.currentRouteName === 'search'
|
this.router.currentRouteName === 'place.new' ||
|
||||||
|
this.router.currentRouteName === 'search')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component {
|
|||||||
handleOutsideClick() {
|
handleOutsideClick() {
|
||||||
if (this.isAppMenuOpen) {
|
if (this.isAppMenuOpen) {
|
||||||
this.closeAppMenu();
|
this.closeAppMenu();
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
} else if (
|
||||||
this.router.transitionTo('index');
|
this.router.currentRouteName === 'search' ||
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
this.router.currentRouteName === 'place'
|
||||||
// If in place route, decide if we want to go back to search or index
|
) {
|
||||||
// For now, let's go to index or maybe back to search if search params exist?
|
this.mapUi.clearSelection();
|
||||||
// Simplest behavior: clear selection
|
this.mapUi.hideSidebar();
|
||||||
this.router.transitionTo('index');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component {
|
|||||||
if (place === null) {
|
if (place === null) {
|
||||||
// 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) {
|
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 +89,26 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
|||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
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 +21,16 @@ 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}}
|
@onSelect={{this.selectPlace}}
|
||||||
/>
|
@onClose={{this.close}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isMobile() {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
@@ -7,3 +7,8 @@ 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);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
|||||||
+34
-10
@@ -2,8 +2,13 @@
|
|||||||
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 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 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';
|
||||||
@@ -18,6 +23,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';
|
||||||
@@ -25,8 +32,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw';
|
|||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
|
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||||
|
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
|
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||||
|
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
@@ -37,9 +48,10 @@ 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 boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
|
||||||
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||||
|
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||||
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
@@ -69,12 +81,9 @@ 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 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 memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
|
||||||
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
|
||||||
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';
|
||||||
@@ -96,6 +105,7 @@ import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_str
|
|||||||
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/scissors_open.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';
|
||||||
@@ -105,11 +115,13 @@ 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';
|
||||||
|
|
||||||
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';
|
||||||
|
import whatsapp from '../icons/whatsapp.svg?raw';
|
||||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -124,11 +136,18 @@ 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-right': chevronRight,
|
||||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
climbing_wall: climbingWall,
|
climbing_wall: climbingWall,
|
||||||
|
check,
|
||||||
|
'alert-circle': alertCircle,
|
||||||
|
'alert-triangle': alertTriangle,
|
||||||
'classical-building': classicalBuilding,
|
'classical-building': classicalBuilding,
|
||||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
@@ -142,6 +161,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,
|
||||||
@@ -163,6 +183,7 @@ const ICONS = {
|
|||||||
heart,
|
heart,
|
||||||
home,
|
home,
|
||||||
'ice-cream-on-cone': iceCreamOnCone,
|
'ice-cream-on-cone': iceCreamOnCone,
|
||||||
|
'industrial-building': industrialBuilding,
|
||||||
info,
|
info,
|
||||||
instagram,
|
instagram,
|
||||||
jewel,
|
jewel,
|
||||||
@@ -172,11 +193,9 @@ const ICONS = {
|
|||||||
mail,
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
'market-stall': marketStall,
|
|
||||||
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
|
||||||
menu,
|
menu,
|
||||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
'more-horizontal': moreHorizontal,
|
||||||
'molar-tooth': molarTooth,
|
'more-vertical': moreVertical,
|
||||||
navigation,
|
navigation,
|
||||||
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||||
nostrich,
|
nostrich,
|
||||||
@@ -203,6 +222,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,
|
||||||
@@ -213,12 +233,16 @@ const ICONS = {
|
|||||||
'tattoo-machine': tattooMachine,
|
'tattoo-machine': tattooMachine,
|
||||||
toolbox,
|
toolbox,
|
||||||
target,
|
target,
|
||||||
|
'trash-2': trash2,
|
||||||
|
'upload-cloud': uploadCloud,
|
||||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
'village-buildings': villageBuildings,
|
'village-buildings': villageBuildings,
|
||||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
|
whatsapp,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
winding_way_wide: windingWayWide,
|
||||||
parking_p: parkingP,
|
parking_p: parkingP,
|
||||||
car,
|
car,
|
||||||
x,
|
x,
|
||||||
@@ -229,10 +253,10 @@ const ICONS = {
|
|||||||
const FILLED_ICONS = [
|
const FILLED_ICONS = [
|
||||||
'fork-and-knife',
|
'fork-and-knife',
|
||||||
'wikipedia',
|
'wikipedia',
|
||||||
|
'whatsapp',
|
||||||
'cup-and-saucer',
|
'cup-and-saucer',
|
||||||
'coffee-bean',
|
'coffee-bean',
|
||||||
'shopping-basket',
|
'shopping-basket',
|
||||||
'camera',
|
|
||||||
'person-sleeping-in-bed',
|
'person-sleeping-in-bed',
|
||||||
'loading-ring',
|
'loading-ring',
|
||||||
'nostrich',
|
'nostrich',
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export function normalizeRelayUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
let normalized = url.trim().toLowerCase();
|
||||||
|
if (!normalized) return '';
|
||||||
|
|
||||||
|
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
|
||||||
|
normalized = 'wss://' + normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||||
|
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||||
|
*
|
||||||
|
* @param {Array} events NIP-360 events
|
||||||
|
* @returns {Array} Array of photo objects
|
||||||
|
*/
|
||||||
|
export function parsePlacePhotos(events) {
|
||||||
|
if (!events || events.length === 0) return [];
|
||||||
|
|
||||||
|
// Sort by created_at ascending (oldest first)
|
||||||
|
const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at);
|
||||||
|
|
||||||
|
const allPhotos = [];
|
||||||
|
|
||||||
|
for (const event of sortedEvents) {
|
||||||
|
const eventTagValues = event.tags
|
||||||
|
.filter((t) => t[0] === 't')
|
||||||
|
.map((t) => t[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Find all imeta tags
|
||||||
|
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||||
|
for (const imeta of imetas) {
|
||||||
|
let url = null;
|
||||||
|
let thumbUrl = null;
|
||||||
|
let blurhash = null;
|
||||||
|
let isLandscape = false;
|
||||||
|
let aspectRatio = 16 / 9; // default
|
||||||
|
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
|
||||||
|
for (const tag of imeta.slice(1)) {
|
||||||
|
if (tag.startsWith('url ')) {
|
||||||
|
url = tag.substring(4);
|
||||||
|
} else if (tag.startsWith('thumb ')) {
|
||||||
|
thumbUrl = tag.substring(6);
|
||||||
|
} else if (tag.startsWith('blurhash ')) {
|
||||||
|
blurhash = tag.substring(9);
|
||||||
|
} else if (tag.startsWith('dim ')) {
|
||||||
|
const dimStr = tag.substring(4);
|
||||||
|
const [width, height] = dimStr.split('x').map(Number);
|
||||||
|
if (width && height) {
|
||||||
|
aspectRatio = width / height;
|
||||||
|
if (width > height) {
|
||||||
|
isLandscape = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
allPhotos.push({
|
||||||
|
eventId: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
url,
|
||||||
|
thumbUrl,
|
||||||
|
blurhash,
|
||||||
|
isLandscape,
|
||||||
|
aspectRatio,
|
||||||
|
placeIdentifier,
|
||||||
|
tags: eventTagValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPhotos.length === 0) return [];
|
||||||
|
|
||||||
|
// Find the first landscape photo
|
||||||
|
const firstLandscapeIndex = allPhotos.findIndex((p) => p.isLandscape);
|
||||||
|
|
||||||
|
if (firstLandscapeIndex > 0) {
|
||||||
|
// Move the first landscape photo to the front
|
||||||
|
const [firstLandscape] = allPhotos.splice(firstLandscapeIndex, 1);
|
||||||
|
allPhotos.unshift(firstLandscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPhotos;
|
||||||
|
}
|
||||||
@@ -109,6 +109,9 @@ export const POI_ICON_RULES = [
|
|||||||
{ 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: 'castle' }, icon: 'palace' },
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
@@ -119,6 +122,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' },
|
||||||
|
|||||||
@@ -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,7 +41,7 @@ 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)$"]',
|
||||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
|
|||||||
id: 'accommodation',
|
id: 'accommodation',
|
||||||
label: 'Hotels',
|
label: 'Hotels',
|
||||||
icon: 'person-sleeping-in-bed',
|
icon: 'person-sleeping-in-bed',
|
||||||
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||||
types: ['node', 'way', 'relation'],
|
types: ['node', 'way', 'relation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { encode } from 'blurhash';
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
// Ignore internal browser/Vite/extension pings that don't match our exact job signature
|
||||||
|
if (e.data?.type !== 'PROCESS_IMAGE') return;
|
||||||
|
|
||||||
|
const { id, file, targetWidth, targetHeight, quality, computeBlurhash } =
|
||||||
|
e.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let finalCanvas;
|
||||||
|
let finalCtx;
|
||||||
|
|
||||||
|
// --- 1. Attempt Hardware Resizing (Happy Path) ---
|
||||||
|
try {
|
||||||
|
const resizedBitmap = await createImageBitmap(file, {
|
||||||
|
resizeWidth: targetWidth,
|
||||||
|
resizeHeight: targetHeight,
|
||||||
|
resizeQuality: 'high',
|
||||||
|
});
|
||||||
|
|
||||||
|
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||||
|
finalCtx = finalCanvas.getContext('2d');
|
||||||
|
if (!finalCtx) {
|
||||||
|
throw new Error('Failed to get 2d context from OffscreenCanvas');
|
||||||
|
}
|
||||||
|
finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight);
|
||||||
|
resizedBitmap.close();
|
||||||
|
} catch (hwError) {
|
||||||
|
console.warn(
|
||||||
|
'Hardware resize failed, falling back to stepped software scaling:',
|
||||||
|
hwError
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 2. Fallback to Stepped Software Scaling (Robust Path) ---
|
||||||
|
// Bypass Android File descriptor bug by reading into memory
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const blob = new Blob([buffer], { type: file.type });
|
||||||
|
|
||||||
|
const source = await createImageBitmap(blob);
|
||||||
|
let srcWidth = source.width;
|
||||||
|
let srcHeight = source.height;
|
||||||
|
|
||||||
|
let currentCanvas = new OffscreenCanvas(srcWidth, srcHeight);
|
||||||
|
let ctx = currentCanvas.getContext('2d');
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(source, 0, 0);
|
||||||
|
|
||||||
|
// Step down by halves until near target
|
||||||
|
while (
|
||||||
|
currentCanvas.width * 0.5 > targetWidth &&
|
||||||
|
currentCanvas.height * 0.5 > targetHeight
|
||||||
|
) {
|
||||||
|
const nextCanvas = new OffscreenCanvas(
|
||||||
|
Math.floor(currentCanvas.width * 0.5),
|
||||||
|
Math.floor(currentCanvas.height * 0.5)
|
||||||
|
);
|
||||||
|
const nextCtx = nextCanvas.getContext('2d');
|
||||||
|
|
||||||
|
nextCtx.imageSmoothingEnabled = true;
|
||||||
|
nextCtx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
nextCtx.drawImage(
|
||||||
|
currentCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
nextCanvas.width,
|
||||||
|
nextCanvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
currentCanvas = nextCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final resize to exact target
|
||||||
|
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||||
|
finalCtx = finalCanvas.getContext('2d');
|
||||||
|
|
||||||
|
finalCtx.imageSmoothingEnabled = true;
|
||||||
|
finalCtx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
finalCtx.drawImage(currentCanvas, 0, 0, targetWidth, targetHeight);
|
||||||
|
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Generate Blurhash (if requested) ---
|
||||||
|
let blurhash = null;
|
||||||
|
if (computeBlurhash) {
|
||||||
|
try {
|
||||||
|
const imageData = finalCtx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight
|
||||||
|
);
|
||||||
|
blurhash = encode(imageData.data, targetWidth, targetHeight, 4, 3);
|
||||||
|
} catch (blurhashError) {
|
||||||
|
console.warn(
|
||||||
|
'Could not generate blurhash (possible canvas fingerprinting protection):',
|
||||||
|
blurhashError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Compress to JPEG Blob ---
|
||||||
|
const finalBlob = await finalCanvas.convertToBlob({
|
||||||
|
type: 'image/jpeg',
|
||||||
|
quality: quality,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dim = `${targetWidth}x${targetHeight}`;
|
||||||
|
|
||||||
|
// --- 5. Send results back to main thread ---
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
success: true,
|
||||||
|
blob: finalBlob,
|
||||||
|
dim,
|
||||||
|
blurhash,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# NIP-XX: Place Photos and Media
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
This NIP defines a standardized event format for sharing photos, videos, and other visual media tied to specific real-world locations (e.g., OpenStreetMap POIs).
|
||||||
|
|
||||||
|
While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP specifically targets map-based applications, travel logs, and location directories by mandating strict entity identifiers (`i` tags) and spatial indexing (`g` tags).
|
||||||
|
|
||||||
|
## Event Kind
|
||||||
|
|
||||||
|
`kind: 360`
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
This NIP relies on existing Nostr tag conventions to link media to places and provide inline metadata.
|
||||||
|
|
||||||
|
### Required Tags
|
||||||
|
|
||||||
|
#### 1. `i` — Entity Identifier
|
||||||
|
|
||||||
|
Identifies the exact place the media depicts using an external identifier (as defined in NIP-73). OpenStreetMap data is the default:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["i", "osm:node:123456"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
|
||||||
|
|
||||||
|
#### 2. `g` — Geohash
|
||||||
|
|
||||||
|
Used for spatial indexing and discovery. Events MUST include at least one high-precision geohash. To optimize for map-based discovery across different zoom levels, clients SHOULD include geohashes at multiple resolutions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["g", "thrr"] // coarse (~city)
|
||||||
|
["g", "thrrn5"] // medium (~1km)
|
||||||
|
["g", "thrrn5k"] // fine (~150m)
|
||||||
|
["g", "thrrn5kxyz"] // exact
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `imeta` — Inline Media Metadata
|
||||||
|
|
||||||
|
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` 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), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||||
|
"m image/jpeg",
|
||||||
|
"dim 1440x1920",
|
||||||
|
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
|
||||||
|
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
|
||||||
|
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
|
||||||
|
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Tags
|
||||||
|
|
||||||
|
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
|
||||||
|
- `content-warning`: If the media contains NSFW or sensitive imagery.
|
||||||
|
- `published_at`: Unix timestamp of when the photo was originally taken or published.
|
||||||
|
|
||||||
|
## Example Event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<32-bytes hex>",
|
||||||
|
"pubkey": "<32-bytes hex>",
|
||||||
|
"created_at": 1713205000,
|
||||||
|
"kind": 360,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
["i", "osm:node:987654321"],
|
||||||
|
["g", "xn0m"],
|
||||||
|
["g", "xn0m7h"],
|
||||||
|
["g", "xn0m7hwq"],
|
||||||
|
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||||
|
"m image/jpeg",
|
||||||
|
"dim 1920x1920",
|
||||||
|
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
|
||||||
|
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
|
||||||
|
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
|
||||||
|
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
|
||||||
|
],
|
||||||
|
|
||||||
|
["t", "ramen"],
|
||||||
|
["t", "food"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Kind 360
|
||||||
|
|
||||||
|
Easy to remember as a 360-degree view of places.
|
||||||
|
|
||||||
|
### Why not use NIP-68 (Picture-first feeds)?
|
||||||
|
|
||||||
|
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
|
||||||
|
|
||||||
|
### Separation from Place Reviews
|
||||||
|
|
||||||
|
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.
|
||||||
|
|
||||||
|
### Single Photo per Event
|
||||||
|
|
||||||
|
Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity.
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# NIP-XX: Place Reviews
|
||||||
|
|
||||||
|
`draft` `optional`
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
|
||||||
|
|
||||||
|
The design prioritizes:
|
||||||
|
|
||||||
|
- Small event size
|
||||||
|
- Interoperability across clients
|
||||||
|
- Flexibility for different place types
|
||||||
|
- Efficient geospatial querying using geohashes
|
||||||
|
|
||||||
|
## Event Kind
|
||||||
|
|
||||||
|
`kind: 30360`
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Additional tags MAY be included by clients but are not defined by this specification.
|
||||||
|
|
||||||
|
This NIP reuses and builds upon existing Nostr tag conventions:
|
||||||
|
|
||||||
|
- `i` tag: see NIP-73 (External Content Identifiers)
|
||||||
|
- `g` tag: geohash-based geotagging (community conventions)
|
||||||
|
|
||||||
|
Where conflicts arise, this NIP specifies the behavior for review events.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
#### `i` — Entity Identifier
|
||||||
|
|
||||||
|
Identifies the reviewed place using an external identifier. OpenStreetMap data is the default:
|
||||||
|
|
||||||
|
```
|
||||||
|
["i", "osm:<type>:<id>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
["i", "osm:node:123456"]
|
||||||
|
["i", "osm:way:987654"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geospatial Tags
|
||||||
|
|
||||||
|
#### `g` — Geohash
|
||||||
|
|
||||||
|
Geohash tags are used for spatial indexing and discovery.
|
||||||
|
|
||||||
|
##### Requirements
|
||||||
|
|
||||||
|
- Clients MUST include at least one high-precision geohash (length ≥ 9)
|
||||||
|
|
||||||
|
##### Recommendations
|
||||||
|
|
||||||
|
Clients SHOULD include geohashes at the following resolutions:
|
||||||
|
|
||||||
|
- length 4 — coarse (city-scale discovery)
|
||||||
|
- length 6 — medium (default query level, ~1 km)
|
||||||
|
- length 7 — fine (neighborhood, ~150 m)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
["g", "thrr"]
|
||||||
|
["g", "thrrn5"]
|
||||||
|
["g", "thrrn5k"]
|
||||||
|
["g", "thrrn5kxyz"]
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Querying
|
||||||
|
|
||||||
|
Geospatial queries are performed using the `g` tag.
|
||||||
|
|
||||||
|
- Clients SHOULD query using a single geohash precision level per request
|
||||||
|
- Clients MAY include multiple geohash values in a filter to cover a bounding box
|
||||||
|
- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
|
||||||
|
- Clients MAY reduce precision or split queries when necessary
|
||||||
|
|
||||||
|
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
|
||||||
|
|
||||||
|
## Content (JSON)
|
||||||
|
|
||||||
|
The event `content` MUST be valid JSON matching the following schema.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "ratings"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"ratings": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["quality"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"quality": { "$ref": "#/$defs/score" },
|
||||||
|
"value": { "$ref": "#/$defs/score" },
|
||||||
|
"experience": { "$ref": "#/$defs/score" },
|
||||||
|
"accessibility": { "$ref": "#/$defs/score" },
|
||||||
|
"aspects": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"maxProperties": 20,
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/score" },
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{1,31}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommend": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"familiarity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"],
|
||||||
|
"description": "User familiarity: low = first visit; medium = occasional; high = frequent"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"visited_at": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1440
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
### Restaurant Review Event
|
||||||
|
|
||||||
|
#### Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
["i", "osm:node:123456"],
|
||||||
|
["g", "thrr"],
|
||||||
|
["g", "thrrn5"],
|
||||||
|
["g", "thrrn5k"],
|
||||||
|
["g", "thrrn5kxyz"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Content
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"ratings": {
|
||||||
|
"quality": 9,
|
||||||
|
"value": 8,
|
||||||
|
"experience": 9,
|
||||||
|
"accessibility": 7,
|
||||||
|
"aspects": {
|
||||||
|
"food": 9,
|
||||||
|
"service": 6,
|
||||||
|
"ambience": 8,
|
||||||
|
"wait_time": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommend": true,
|
||||||
|
"familiarity": "medium",
|
||||||
|
"context": {
|
||||||
|
"visited_at": 1713200000,
|
||||||
|
"duration_minutes": 90,
|
||||||
|
"party_size": 2
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"text": "Excellent food with bold flavors. Service was a bit slow, but the atmosphere made up for it.",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
### Ratings
|
||||||
|
|
||||||
|
- Scores are integers from 1 to 10
|
||||||
|
- `quality` is required and represents the core evaluation of the place
|
||||||
|
- Other fields are optional and context-dependent
|
||||||
|
|
||||||
|
### Aspects
|
||||||
|
|
||||||
|
- Free-form keys allow domain-specific ratings
|
||||||
|
- Clients MAY define and interpret aspect keys
|
||||||
|
- Clients SHOULD reuse commonly established aspect keys where possible
|
||||||
|
|
||||||
|
## Recommendation Signal
|
||||||
|
|
||||||
|
The `recommend` field represents a binary verdict:
|
||||||
|
|
||||||
|
- `true` → user recommends the place
|
||||||
|
- `false` → user does not recommend the place
|
||||||
|
|
||||||
|
Clients SHOULD strongly encourage users to provide this value.
|
||||||
|
|
||||||
|
## Familiarity
|
||||||
|
|
||||||
|
Represents user familiarity with the place:
|
||||||
|
|
||||||
|
- `low` → first visit or limited exposure
|
||||||
|
- `medium` → occasional visits
|
||||||
|
- `high` → frequent or expert-level familiarity
|
||||||
|
|
||||||
|
Clients MAY use this signal for weighting during aggregation.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Optional metadata about the visit.
|
||||||
|
|
||||||
|
- `visited_at` is a Unix timestamp
|
||||||
|
- `duration_minutes` represents time spent
|
||||||
|
- `party_size` indicates group size
|
||||||
|
|
||||||
|
## Interoperability
|
||||||
|
|
||||||
|
This specification defines a content payload only.
|
||||||
|
|
||||||
|
- In Nostr: place identity is conveyed via tags
|
||||||
|
- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
|
||||||
|
|
||||||
|
Content payloads SHOULD NOT include place identifiers.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Kind 30360
|
||||||
|
|
||||||
|
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
|
||||||
|
|
||||||
|
### No Place Field in Content
|
||||||
|
|
||||||
|
Avoids duplication and inconsistency with tags.
|
||||||
|
|
||||||
|
### Multi-Aspect Ratings
|
||||||
|
|
||||||
|
Separates concerns (e.g. quality vs service), improving signal quality.
|
||||||
|
|
||||||
|
### Recommendation vs Score
|
||||||
|
|
||||||
|
Binary recommendation avoids averaging pitfalls and improves ranking.
|
||||||
|
|
||||||
|
### Familiarity
|
||||||
|
|
||||||
|
Provides a human-friendly proxy for confidence without requiring numeric input.
|
||||||
|
|
||||||
|
### Geohash Strategy
|
||||||
|
|
||||||
|
Multiple resolutions balance:
|
||||||
|
|
||||||
|
- efficient querying
|
||||||
|
- small event size
|
||||||
|
- early-stage discoverability
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
- Standardized aspect vocabularies
|
||||||
|
- Reputation and weighting models
|
||||||
|
- Indexing/aggregation services
|
||||||
|
- Cross-protocol mappings
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Clients SHOULD validate all input
|
||||||
|
- Malicious or spam reviews may require external moderation or reputation systems
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
|
||||||
|
- NIP 68/92/94 for place photos and image metadata (add "i" tag for "osm:node:123456" to NIP-68)
|
||||||
|
- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
|
||||||
|
- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
|
||||||
|
- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Ranking Algorithm
|
||||||
|
|
||||||
|
Your inputs:
|
||||||
|
|
||||||
|
- many users
|
||||||
|
- partial ratings
|
||||||
|
- different priorities
|
||||||
|
|
||||||
|
Your output:
|
||||||
|
|
||||||
|
> “Best place _for this user right now_”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Normalize scores
|
||||||
|
|
||||||
|
Convert 1–10 → 0–1:
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalized_score = (score - 1) / 9
|
||||||
|
```
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- easier math
|
||||||
|
- comparable across aspects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Per-aspect aggregation (avoid averages trap)
|
||||||
|
|
||||||
|
Instead of mean, compute:
|
||||||
|
|
||||||
|
### A. Positive ratio
|
||||||
|
|
||||||
|
```text
|
||||||
|
positive = score >= 7
|
||||||
|
negative = score <= 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
positive_ratio = positive_votes / total_votes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Confidence-weighted score
|
||||||
|
|
||||||
|
Use something like a **Wilson score interval** (this is key):
|
||||||
|
|
||||||
|
- prevents small-sample abuse
|
||||||
|
- avoids “1 review = #1 place”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Build aspect scores
|
||||||
|
|
||||||
|
For each aspect:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aspect_score = f(
|
||||||
|
positive_ratio,
|
||||||
|
confidence,
|
||||||
|
number_of_reviews
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can approximate with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aspect_score = positive_ratio * log(1 + review_count)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Simple, works surprisingly well)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: User preference weighting
|
||||||
|
|
||||||
|
User defines:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quality": 0.5,
|
||||||
|
"value": 0.2,
|
||||||
|
"service": 0.2,
|
||||||
|
"speed": 0.1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
final_score = Σ (aspect_score × weight)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Context filtering (this is your unfair advantage)
|
||||||
|
|
||||||
|
Filter reviews before scoring:
|
||||||
|
|
||||||
|
- time-based:
|
||||||
|
- “last 6 months”
|
||||||
|
|
||||||
|
- context-based:
|
||||||
|
- lunch vs dinner
|
||||||
|
- solo vs group
|
||||||
|
|
||||||
|
This is something centralized platforms barely do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Reviewer weighting (later, but powerful)
|
||||||
|
|
||||||
|
Weight reviews by:
|
||||||
|
|
||||||
|
- consistency
|
||||||
|
- similarity to user preferences
|
||||||
|
- past agreement
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
|
||||||
|
> “people like you liked this”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Example end-to-end
|
||||||
|
|
||||||
|
### Raw reviews:
|
||||||
|
|
||||||
|
| User | Food | Service |
|
||||||
|
| ---- | ---- | ------- |
|
||||||
|
| A | 9 | 4 |
|
||||||
|
| B | 8 | 5 |
|
||||||
|
| C | 10 | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Derived:
|
||||||
|
|
||||||
|
- food → high positive ratio (~100%)
|
||||||
|
- service → low (~33%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User preferences:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"food": 0.8,
|
||||||
|
"service": 0.2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ ranks high
|
||||||
|
|
||||||
|
Another user:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"food": 0.3,
|
||||||
|
"service": 0.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ ranks low
|
||||||
|
|
||||||
|
👉 Same data, different truth
|
||||||
|
That’s your killer feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Critical design choices (don’t skip these)
|
||||||
|
|
||||||
|
## A. No global score in protocol
|
||||||
|
|
||||||
|
Let clients compute it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Embrace incomplete data
|
||||||
|
|
||||||
|
Most reviews will have:
|
||||||
|
|
||||||
|
- 1–3 aspects only
|
||||||
|
|
||||||
|
That’s fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Time decay (important)
|
||||||
|
|
||||||
|
Recent reviews should matter more:
|
||||||
|
|
||||||
|
```text
|
||||||
|
weight = e^(-λ × age)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Anti-gaming baseline
|
||||||
|
|
||||||
|
Even in nostr:
|
||||||
|
|
||||||
|
- spam will happen
|
||||||
|
|
||||||
|
Mitigation later:
|
||||||
|
|
||||||
|
- require minimum interactions
|
||||||
|
- reputation layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. What you’ve built (zooming out)
|
||||||
|
|
||||||
|
This is not a review system.
|
||||||
|
|
||||||
|
It’s:
|
||||||
|
|
||||||
|
> A decentralized, multi-dimensional reputation graph for real-world places
|
||||||
|
|
||||||
|
That’s much bigger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Next step (if you want to go deeper)
|
||||||
|
|
||||||
|
We can design:
|
||||||
|
|
||||||
|
### A. Query layer
|
||||||
|
|
||||||
|
- how clients fetch & merge nostr reviews efficiently
|
||||||
|
|
||||||
|
### B. Anti-spam / trust model
|
||||||
|
|
||||||
|
- web-of-trust
|
||||||
|
- staking / reputation
|
||||||
|
|
||||||
|
### C. OSM integration details
|
||||||
|
|
||||||
|
- handling duplicates
|
||||||
|
- POI identity conflicts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If I had to pick one next:
|
||||||
|
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Canonical Aspect Vocabulary (v0.1)
|
||||||
|
|
||||||
|
## A. Core universal aspects
|
||||||
|
|
||||||
|
These should work for _any_ place:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"quality", // core offering (food, repair, exhibits, etc.)
|
||||||
|
"value", // value for money/time
|
||||||
|
"experience", // comfort, usability, vibe
|
||||||
|
"accessibility" // ease of access, inclusivity
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why these work
|
||||||
|
|
||||||
|
- **quality** → your “product” abstraction (critical)
|
||||||
|
- **value** → universally meaningful signal
|
||||||
|
- **experience** → captures everything “soft”
|
||||||
|
- **accessibility** → often ignored but high utility
|
||||||
|
|
||||||
|
👉 Resist adding more. Every extra “universal” weakens the concept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Common cross-domain aspects (recommended pool)
|
||||||
|
|
||||||
|
Not universal, but widely reusable:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"service", // human interaction
|
||||||
|
"speed", // waiting time / turnaround
|
||||||
|
"cleanliness",
|
||||||
|
"safety",
|
||||||
|
"reliability",
|
||||||
|
"atmosphere"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
These apply to:
|
||||||
|
|
||||||
|
- restaurants, garages, clinics, parks, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Domain-specific examples (NOT standardized)
|
||||||
|
|
||||||
|
Let clients define freely:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"restaurant": ["food", "drinks"],
|
||||||
|
"bar": ["drinks", "music"],
|
||||||
|
"garage": ["work_quality", "honesty"],
|
||||||
|
"park": ["greenery", "amenities"],
|
||||||
|
"museum": ["exhibits", "crowding"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Key rule (this prevents chaos)
|
||||||
|
|
||||||
|
👉 **Aspect keys MUST be lowercase snake_case**
|
||||||
|
|
||||||
|
👉 **Meaning is defined socially, not technically**
|
||||||
|
|
||||||
|
To reduce fragmentation:
|
||||||
|
|
||||||
|
- publish a **public registry (GitHub repo)**
|
||||||
|
- clients can:
|
||||||
|
- suggest additions
|
||||||
|
- map synonyms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Optional normalization hint (important later)
|
||||||
|
|
||||||
|
Allow this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"aspect_aliases": {
|
||||||
|
"food": "quality",
|
||||||
|
"work_quality": "quality"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Not required, but useful for aggregation engines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Map familiarity in UI to:
|
||||||
|
|
||||||
|
- high: “I know this place well”
|
||||||
|
- medium: “Been a few times”
|
||||||
|
- low: “First visit”
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://example.org/schemas/place-review.json",
|
||||||
|
"title": "Decentralized Place Review (Nostr/Event Content)",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "place", "ratings"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"ratings": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["quality"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"quality": { "$ref": "#/$defs/score" },
|
||||||
|
"value": { "$ref": "#/$defs/score" },
|
||||||
|
"experience": { "$ref": "#/$defs/score" },
|
||||||
|
"accessibility": { "$ref": "#/$defs/score" },
|
||||||
|
|
||||||
|
"aspects": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"maxProperties": 20,
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/score" },
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{1,31}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"recommend": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the user recommends this place to others"
|
||||||
|
},
|
||||||
|
|
||||||
|
"familiarity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"],
|
||||||
|
"description": "User familiarity with the place. Suggested interpretation: 'low' = first visit or very limited experience; 'medium' = visited a few times or moderate familiarity; 'high' = frequent visitor or strong familiarity."
|
||||||
|
},
|
||||||
|
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"visited_at": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1440
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"review": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"$defs": {
|
||||||
|
"score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.18.1",
|
"version": "1.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||||
"qunit": "^2.25.0",
|
"qunit": "^2.25.0",
|
||||||
"qunit-dom": "^3.5.0",
|
"qunit-dom": "^3.5.0",
|
||||||
"remotestorage-widget": "^1.8.0",
|
"remotestorage-widget": "^1.8.1",
|
||||||
"remotestoragejs": "2.0.0-beta.8",
|
"remotestoragejs": "2.0.0-beta.9",
|
||||||
"sinon": "^21.0.1",
|
"sinon": "^21.0.1",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
@@ -102,9 +102,18 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
"@waysidemapping/pinhead": "^15.20.0",
|
"@waysidemapping/pinhead": "^15.20.0",
|
||||||
|
"applesauce-core": "^5.2.0",
|
||||||
|
"applesauce-factory": "^4.0.0",
|
||||||
|
"applesauce-relay": "^5.2.0",
|
||||||
|
"applesauce-signers": "^5.2.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.0.0",
|
||||||
"oauth2-pkce": "^2.1.3"
|
"nostr-idb": "^5.0.0",
|
||||||
|
"oauth2-pkce": "^2.1.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+965
-30
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const t=Object.freeze(Object.defineProperty({__proto__:null,get default(){return LG}},Symbol.toStringTag,{value:"Module"}));!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();const e="object"==typeof self&&null!==self&&self.Object===Object&&"undefined"!=typeof Window&&self.constructor===Window&&"object"==typeof document&&null!==document&&self.document===document&&"object"==typeof location&&null!==location&&self.location===location&&"object"==typeof history&&null!==history&&self.history===history&&"object"==typeof navigator&&null!==navigator&&self.navigator===navigator&&"string"==typeof navigator.userAgent,n=e?self:null,i=e?self.location:null,r=e?self.history:null,s=e?self.navigator.userAgent:"Lynx (textmode)",o=!(!e||"object"!=typeof chrome||"object"==typeof opera),a=!!e&&/Firefox|FxiOS/.test(s),l=Object.freeze(Object.defineProperty({__proto__:null,hasDOM:e,history:r,isChrome:o,isFirefox:a,location:i,userAgent:s,window:n},Symbol.toStringTag,{value:"Module"}));function c(t){return t&&t.Object===Object?t:void 0}const h=c((u="object"==typeof global&&global)&&void 0===u.nodeType?u:void 0)||c("object"==typeof self&&self)||c("object"==typeof window&&window)||"undefined"!=typeof mainContext&&mainContext||new Function("return this")();var u;const d=function(t,e){return void 0===e?{imports:t,exports:t,lookup:t}:{imports:e.imports||t,exports:e.exports||t,lookup:e.lookup||t}}(h,h.Ember);function p(){return d.lookup}function f(t){d.lookup=t}const g={ENABLE_OPTIONAL_FEATURES:!1,EXTEND_PROTOTYPES:{Array:!1},LOG_STACKTRACE_ON_DEPRECATION:!0,LOG_VERSION:!0,RAISE_ON_DEPRECATION:!1,STRUCTURED_PROFILE:!1,_DEBUG_RENDER_TREE:!1,_ALL_DEPRECATIONS_ENABLED:!1,_OVERRIDE_DEPRECATION_VERSION:null,_DEFAULT_ASYNC_OBSERVERS:!1,_RERENDER_LOOP_LIMIT:1e3,EMBER_LOAD_HOOKS:{},FEATURES:{}};function m(){return g}(t=>{if("object"!=typeof t||null===t)return;for(let i in t){if(!Object.prototype.hasOwnProperty.call(t,i)||"EXTEND_PROTOTYPES"===i||"EMBER_LOAD_HOOKS"===i)continue;let e=g[i];g[i]=!0===e?!1!==t[i]:!1===e?!0===t[i]:t[i]}let{EMBER_LOAD_HOOKS:e}=t;if("object"==typeof e&&null!==e)for(let i in e){if(!Object.prototype.hasOwnProperty.call(e,i))continue;let t=e[i];Array.isArray(t)&&(g.EMBER_LOAD_HOOKS[i]=t.filter(t=>"function"==typeof t))}let{FEATURES:n}=t;if("object"==typeof n&&null!==n)for(let i in n)Object.prototype.hasOwnProperty.call(n,i)&&(g.FEATURES[i]=!0===n[i])})(h.EmberENV);let y=()=>{},v=()=>{},_=!1;function b(){return _}function w(t){_=Boolean(t)}let x=()=>{};const{toString:C}=Object.prototype,{toString:S}=Function.prototype,{isArray:k}=Array,{keys:E}=Object,{stringify:T}=JSON,L=100,M=/^[\w$]+$/;function R(t){return"number"==typeof t&&2===arguments.length?this:P(t,0)}function P(t,e,n){let i=!1;switch(typeof t){case"undefined":return"undefined";case"object":if(null===t)return"null";if(k(t)){i=!0;break}if(t.toString===C||void 0===t.toString)break;return t.toString();case"function":return t.toString===S?t.name?`[Function:${t.name}]`:"[Function]":t.toString();case"string":return T(t);default:return t.toString()}if(void 0===n)n=new WeakSet;else if(n.has(t))return"[Circular]";return n.add(t),i?function(t,e,n){if(e>4)return"[Array]";let i="[";for(let r=0;r<t.length;r++){if(i+=0===r?" ":", ",r>=L){i+=`... ${t.length-L} more items`;break}i+=P(t[r],e,n)}return i+=" ]",i}(t,e+1,n):function(t,e,n){if(e>4)return"[Object]";let i="{",r=E(t);for(let s=0;s<r.length;s++){if(i+=0===s?" ":", ",s>=L){i+=`... ${r.length-L} more keys`;break}let o=r[s];i+=`${I(String(o))}: ${P(t[o],e,n)}`}return i+=" }",i}(t,e+1,n)}function I(t){return M.test(t)?t:T(t)}function O(t){let e=t.lookup("renderer:-dom");if(!e)throw new Error("BUG: owner is missing renderer");return e.debugRenderTree.capture()}const A=()=>{};let z=A,F=A,j=A,D=A,N=A,U=A,B=A,q=A,V=function(){return arguments[arguments.length-1]};function $(){}const G=Object.freeze(Object.defineProperty({__proto__:null,_warnIfUsingStrippedFeatureFlags:void 0,assert:y,captureRenderTree:O,debug:j,debugFreeze:N,debugSeal:D,deprecate:$,deprecateFunc:V,getDebugFunction:q,info:z,inspect:R,isTesting:b,registerDeprecationHandler:v,registerWarnHandler:x,runInDebug:U,setDebugFunction:B,setTesting:w,warn:F},Symbol.toStringTag,{value:"Module"}));function W(t){let e=Object.create(null);e[t]=1;for(let n in e)if(n===t)return n;return t}function H(t){return null!==t&&("object"==typeof t||"fLine truncated
|
|
||||||
/*! For license information please see remotestorage.js.LICENSE.txt */const wC=yC(bC),xC="0123456789bcdefghjkmnpqrstuvwxyz";class CC{static encode(t,e,n){if(void 0===n){for(let n=1;n<=12;n++){const i=CC.encode(t,e,n),r=CC.decode(i);if(r.lat==t&&r.lon==e)return i}n=12}if(t=Number(t),e=Number(e),n=Number(n),isNaN(t)||isNaN(e)||isNaN(n))throw new Error("Invalid geohash");let i=0,r=0,s=!0,o="",a=-90,l=90,c=-180,h=180;for(;o.length<n;){if(s){const t=(c+h)/2;e>=t?(i=2*i+1,c=t):(i*=2,h=t)}else{const e=(a+l)/2;t>=e?(i=2*i+1,a=e):(i*=2,l=e)}s=!s,5==++r&&(o+=xC.charAt(i),r=0,i=0)}return o}static decode(t){const e=CC.bounds(t),n=e.sw.lat,i=e.sw.lon,r=e.ne.lat,s=e.ne.lon;let o=(n+r)/2,a=(i+s)/2;return o=o.toFixed(Math.floor(2-Math.log(r-n)/Math.LN10)),a=a.toFixed(Math.floor(2-Math.log(s-i)/Math.LN10)),{lat:Number(o),lon:Number(a)}}static bounds(t){if(0==t.length)throw new Error("Invalid geohash");t=t.toLowerCase();let e=!0,n=-90,i=90,r=-180,s=180;for(let o=0;o<t.length;o++){const a=t.charAt(o),l=xC.indexOf(a);if(-1==l)throw new Error("Invalid geohash");for(let t=4;t>=0;t--){const o=l>>t&1;if(e){const t=(r+s)/2;1==o?r=t:s=t}else{const t=(n+i)/2;1==o?n=t:i=t}e=!e}}return{sw:{lat:n,lon:r},ne:{lat:i,lon:s}}}static adjacent(t,e){if(t=t.toLowerCase(),e=e.toLowerCase(),0==t.length)throw new Error("Invalid geohash");if(-1=="nsew".indexOf(e))throw new Error("Invalid direction");const n=t.slice(-1);let i=t.slice(0,-1);const r=t.length%2;return-1!={n:["prxz","bcfguvyz"],s:["028b","0145hjnp"],e:["bcfguvyz","prxz"],w:["0145hjnp","028b"]}[e][r].indexOf(n)&&""!=i&&(i=CC.adjacent(i,e)),i+xC.charAt({n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"],s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"],e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"],w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"]}[e][r].indexOf(n))}static neighbours(t){return{n:CC.adjacent(t,"n"),ne:CC.adjacent(CC.adjacent(t,"n"),"e"),e:CC.adjacent(t,"e"),se:CC.adjacent(CC.adjacent(t,"s"),"e"),s:CC.adjacent(t,"s"),sw:CC.adjacent(CC.adjacent(t,"s"),"w"),w:CC.adjacent(t,"w"),nw:CC.adjacent(CC.adjacent(t,"n"),"w")}}}const SC="0123456789ABCDEFGHJKMNPQRSTVWXYZ",kC=0xffffffffffff;var EC;!function(t){t.Base32IncorrectEncoding="B32_ENC_INVALID",t.DecodeTimeInvalidCharacter="DEC_TIME_CHAR",t.DecodeTimeValueMalformed="DEC_TIME_MALFORMED",t.EncodeTimeNegative="ENC_TIME_NEG",t.EncodeTimeSizeExceeded="ENC_TIME_SIZE_EXCEED",t.EncodeTimeValueMalformed="ENC_TIME_MALFORMED",t.PRNGDetectFailure="PRNG_DETECT",t.ULIDInvalid="ULID_INVALID",t.Unexpected="UNEXPECTED",t.UUIDInvalid="UUID_INVALID"}(EC||(EC={}));class TC extends Error{constructor(t,e){super(`${e} (${t})`),this.name="ULIDError",this.code=t}}function LC(t){const e=Math.floor(32*t())%32;return SC.charAt(e)}const MC={type:"object",properties:{id:{type:"string"},title:{type:"string"},lat:{type:"number"},lon:{type:"number"},geohash:{type:"string"},zoom:{type:"number"},url:{type:"string"},osmId:{type:"string"},osmType:{type:"string"},osmTags:{type:"object",additionalProperties:{type:"string"}},description:{type:"string"},tags:{type:"array",items:{type:"string"},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","lat","lon","geohash","createdAt"]},RC={type:"object",properties:{id:{type:"string"},title:{type:"string"},color:{type:"string"},placeRefs:{type:"array",items:{type:"object",properties:{id:{type:"string"},geohash:{type:"string"}},required:["id","geohash"]},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","placeRefs","createdAt"]},PC={name:"places",builder:function(t){function e(t,e){return`${t.substring(0,2)}/${t.substring(2,4)}/${e}`}t.declareType("place",MC),t.declareType("list",RC);const n={async getAll(){const e=await t.getAll("_lists/");return e?Object.values(e):[]},async get(e){const n=`_lists/${e}`;return t.getObject(n)},async create(e,n,i){const r=`_lists/${e}`;let s=await t.getObject(r);const o=(new Date).toISOString();return s?(s.title=n,i&&(s.color=i),s.updatedAt=o):s={id:e,title:n,color:i,placeRefs:[],createdAt:o,updatedAt:o},await t.storeObject("list",r,s),s},async delete(e){await t.remove(`_lists/${e}`)},async addPlace(e,n,i){const r=`_lists/${e}`,s=await t.getObject(r);if(!s)throw new Error(`List not found: ${e}`);return-1===s.placeRefs.findIndex(t=>t.id===n)&&(s.placeRefs.push({id:n,geohash:i}),s.updatedAt=(new Date).toISOString(),await t.storeObject("list",r,s)),s},async removePlace(e,n){const i=`_lists/${e}`,r=await t.getObject(i);if(!r)throw new Error(`List not found: ${e}`);const s=r.placeRefs.findIndex(t=>t.id===n);return-1!==s&&(r.placeRefs.splice(s,1),r.updatedAt=(new Date).toISOString(),await t.storeObject("list",i,r)),r},async initDefaults(){const t=[{id:"to-go",title:"Want to go",color:"#2e9e4f"},{id:"to-do",title:"To do",color:"#2a7fff"}];for(const e of t)awLine truncated
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const amdModule0=Object.freeze(Object.defineProperty({__proto__:null,get default(){return App}},Symbol.toStringTag,{value:"Module"}));!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))t(e);new MutationObserver(e=>{for(const r of e)if("childList"===r.type)for(const e of r.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&t(e)}).observe(document,{childList:!0,subtree:!0})}function t(e){if(e.ep)return;e.ep=!0;const t=function(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?t.credentials="include":"anonymous"===e.crossOrigin?t.credentials="omit":t.credentials="same-origin",t}(e);fetch(e.href,t)}}();const hasDOM="object"==typeof self&&null!==self&&self.Object===Object&&"undefined"!=typeof Window&&self.constructor===Window&&"object"==typeof document&&null!==document&&self.document===document&&"object"==typeof location&&null!==location&&self.location===location&&"object"==typeof history&&null!==history&&self.history===history&&"object"==typeof navigator&&null!==navigator&&self.navigator===navigator&&"string"==typeof navigator.userAgent,window$1=hasDOM?self:null,location$1=hasDOM?self.location:null,history$1=hasDOM?self.history:null,userAgent=hasDOM?self.navigator.userAgent:"Lynx (textmode)",isChrome=!(!hasDOM||"object"!=typeof chrome||"object"==typeof opera),isFirefox=!!hasDOM&&/Firefox|FxiOS/.test(userAgent),environment=Object.freeze(Object.defineProperty({__proto__:null,hasDOM:hasDOM,history:history$1,isChrome:isChrome,isFirefox:isFirefox,location:location$1,userAgent:userAgent,window:window$1},Symbol.toStringTag,{value:"Module"}));function checkGlobal(e){return e&&e.Object===Object?e:void 0}function checkElementIdShadowing(e){return e&&void 0===e.nodeType?e:void 0}const global$1=checkGlobal(checkElementIdShadowing("object"==typeof global&&global))||checkGlobal("object"==typeof self&&self)||checkGlobal("object"==typeof window&&window)||"undefined"!=typeof mainContext&&mainContext||new Function("return this")(),context$1=function(e,t){return void 0===t?{imports:e,exports:e,lookup:e}:{imports:t.imports||e,exports:t.exports||e,lookup:t.lookup||e}}(global$1,global$1.Ember);function getLookup(){return context$1.lookup}function setLookup(e){context$1.lookup=e}const ENV$1={ENABLE_OPTIONAL_FEATURES:!1,EXTEND_PROTOTYPES:{Array:!1},LOG_STACKTRACE_ON_DEPRECATION:!0,LOG_VERSION:!0,RAISE_ON_DEPRECATION:!1,STRUCTURED_PROFILE:!1,_DEBUG_RENDER_TREE:!1,_ALL_DEPRECATIONS_ENABLED:!1,_OVERRIDE_DEPRECATION_VERSION:null,_DEFAULT_ASYNC_OBSERVERS:!1,_RERENDER_LOOP_LIMIT:1e3,EMBER_LOAD_HOOKS:{},FEATURES:{}};function getENV(){return ENV$1}(e=>{if("object"!=typeof e||null===e)return;for(let n in e){if(!Object.prototype.hasOwnProperty.call(e,n)||"EXTEND_PROTOTYPES"===n||"EMBER_LOAD_HOOKS"===n)continue;let t=ENV$1[n];ENV$1[n]=!0===t?!1!==e[n]:!1===t?!0===e[n]:e[n]}let{EMBER_LOAD_HOOKS:t}=e;if("object"==typeof t&&null!==t)for(let n in t){if(!Object.prototype.hasOwnProperty.call(t,n))continue;let e=t[n];Array.isArray(e)&&(ENV$1.EMBER_LOAD_HOOKS[n]=e.filter(e=>"function"==typeof e))}let{FEATURES:r}=e;if("object"==typeof r&&null!==r)for(let n in r)Object.prototype.hasOwnProperty.call(r,n)&&(ENV$1.FEATURES[n]=!0===r[n])})(global$1.EmberENV);let assert$3=()=>{},registerHandler$1=()=>{},deprecate$1=()=>{};const defaultDeprecate=deprecate$1;let testing=!1;function isTesting(){return testing}function setTesting(e){testing=Boolean(e)}let registerHandler=()=>{};const{toString:objectToString$1}=Object.prototype,{toString:functionToString}=Function.prototype,{isArray:isArray$6}=Array,{keys:objectKeys}=Object,{stringify:stringify}=JSON,LIST_LIMIT=100,DEPTH_LIMIT=4,SAFE_KEY=/^[\w$]+$/;function inspect(e){return"number"==typeof e&&2===arguments.length?this:inspectValue(e,0)}function inspectValue(e,t,r){let n=!1;switch(typeof e){case"undefined":return"undefined";case"object":if(null===e)return"null";if(isArray$6(e)){n=!0;break}if(e.toString===objectToString$1||void 0===e.toString)break;return e.toString();case"function":return e.toString===functionToString?e.name?`[Function:${e.name}]`:"[Function]":e.toString();case"string":return stringify(e);default:return e.toString()}if(void 0===r)r=new WeakSet;else if(r.has(e))return"[Circular]";return r.add(e),n?inspectArray(e,t+1,r):inspectObject(e,t+1,r)}function inspectKey(e){return SAFE_KEY.test(e)?e:stringify(e)}function inspectObject(e,t,r){if(t>DEPTH_LIMIT)return"[Object]";let n="{",i=objectKeys(e);for(let o=0;o<i.length;o++){if(n+=0===o?" ":", ",o>=LIST_LIMIT){n+=`... ${i.length-LIST_LIMIT} more keys`;break}let s=i[o];n+=`${inspectKey(String(s))}: ${inspectValue(e[s],t,r)}`}return n+=" }",n}function inspectArray(e,t,r){if(t>DEPTH_LIMIT)return"[Array]";let n="[";for(let i=0;i<e.length;i++){if(n+=0===i?" ":", ",i>=LIST_LIMIT){n+=`... ${e.length-LIST_LIMIT} more items`;break}n+=insLine truncated
|
||||||
|
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */if(!isLE$3)throw new Error("Non little-endian hardware is not supported");function utf8ToBytes$2(e){if("string"!=typeof e)throw new Error("utf8ToBytes expected string, got "+typeof e);return new Uint8Array((new TextEncoder).encode(e))}function toBytes$1(e){if("string"==typeof e&&(e=utf8ToBytes$2(e)),!u8a$2(e))throw new Error("expected Uint8Array, got "+typeof e);return e}function concatBytes$3(...e){const t=new Uint8Array(e.reduce((e,t)=>e+t.length,0));let r=0;return e.forEach(e=>{if(!u8a$2(e))throw new Error("Uint8Array expected");t.set(e,r),r+=e.length}),t}let Hash$1=class{clone(){return this._cloneInto()}};function wrapConstructor$1(e){const t=t=>e().update(toBytes$1(t)).digest(),r=e();return t.outputLen=r.outputLen,t.blockLen=r.blockLen,t.create=()=>e(),t}function randomBytes$2(e=32){if(crypto$2&&"function"==typeof crypto$2.getRandomValues)return crypto$2.getRandomValues(new Uint8Array(e));throw new Error("crypto.getRandomValues must be defined")}function setBigUint64$1(e,t,r,n){if("function"==typeof e.setBigUint64)return e.setBigUint64(t,r,n);const i=BigInt(32),o=BigInt(4294967295),s=Number(r>>i&o),a=Number(r&o),l=n?4:0,c=n?0:4;e.setUint32(t+l,s,n),e.setUint32(t+c,a,n)}let SHA2$1=class extends Hash$1{constructor(e,t,r,n){super(),this.blockLen=e,this.outputLen=t,this.padOffset=r,this.isLE=n,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(e),this.view=createView$3(this.buffer)}update(e){exists$1(this);const{view:t,buffer:r,blockLen:n}=this,i=(e=toBytes$1(e)).length;for(let o=0;o<i;){const s=Math.min(n-this.pos,i-o);if(s===n){const t=createView$3(e);for(;n<=i-o;o+=n)this.process(t,o);continue}r.set(e.subarray(o,o+s),this.pos),this.pos+=s,o+=s,this.pos===n&&(this.process(t,0),this.pos=0)}return this.length+=e.length,this.roundClean(),this}digestInto(e){exists$1(this),output$1(e,this),this.finished=!0;const{buffer:t,view:r,blockLen:n,isLE:i}=this;let{pos:o}=this;t[o++]=128,this.buffer.subarray(o).fill(0),this.padOffset>n-o&&(this.process(r,0),o=0);for(let u=o;u<n;u++)t[u]=0;setBigUint64$1(r,n-8,BigInt(8*this.length),i),this.process(r,0);const s=createView$3(e),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const l=a/4,c=this.get();if(l>c.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u<l;u++)s.setUint32(4*u,c[u],i)}digest(){const{buffer:e,outputLen:t}=this;this.digestInto(e);const r=e.slice(0,t);return this.destroy(),r}_cloneInto(e){e||(e=new this.constructor),e.set(...this.get());const{blockLen:t,buffer:r,length:n,finished:i,destroyed:o,pos:s}=this;return e.length=n,e.pos=s,e.finished=i,e.destroyed=o,n%t&&e.buffer.set(r),e}};const Chi$3=(e,t,r)=>e&t^~e&r,Maj$3=(e,t,r)=>e&t^e&r^t&r,SHA256_K$3=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),IV$1=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),SHA256_W$3=new Uint32Array(64);let SHA256$1=class extends SHA2$1{constructor(){super(64,32,8,!1),this.A=0|IV$1[0],this.B=0|IV$1[1],this.C=0|IV$1[2],this.D=0|IV$1[3],this.E=0|IV$1[4],this.F=0|IV$1[5],this.G=0|IV$1[6],this.H=0|IV$1[7]}get(){const{A:e,B:t,C:r,D:n,E:i,F:o,G:s,H:a}=this;return[e,t,r,n,i,o,s,a]}set(e,t,r,n,i,o,s,a){this.A=0|e,this.B=0|t,this.C=0|r,this.D=0|n,this.E=0|i,this.F=0|o,this.G=0|s,this.H=0|a}process(e,t){for(let u=0;u<16;u++,t+=4)SHA256_W$3[u]=e.getUint32(t,!1);for(let u=16;u<64;u++){const e=SHA256_W$3[u-15],t=SHA256_W$3[u-2],r=rotr$3(e,7)^rotr$3(e,18)^e>>>3,n=rotr$3(t,17)^rotr$3(t,19)^t>>>10;SHA256_W$3[u]=n+SHA256_W$3[u-7]+r+SHA256_W$3[u-16]|0}let{A:r,B:n,C:i,D:o,E:s,F:a,G:l,H:c}=this;for(let u=0;u<64;u++){const e=c+(rotr$3(s,6)^rotr$3(s,11)^rotr$3(s,25))+Chi$3(s,a,l)+SHA256_K$3[u]+SHA256_W$3[u]|0,t=(rotr$3(r,2)^rotr$3(r,13)^rotr$3(r,22))+Maj$3(r,n,i)|0;c=l,l=a,a=s,s=o+e|0,o=i,i=n,n=r,r=e+t|0}r=r+this.A|0,n=n+this.B|0,i=i+this.C|0,o=o+this.D|0,s=s+this.E|0,a=a+this.F|0,l=l+this.G|0,c=c+this.H|0,this.set(r,n,i,o,s,a,l,c)}roundClean(){SHA256_W$3.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}};const sha256$3=wrapConstructor$1(()=>new SHA256$1),_0n$a=BigInt(0),_1n$9=BigInt(1),_2n$6=BigInt(2),u8a$1=e=>e instanceof Uint8Array,hexes$4=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function bytesToHex$4(e){if(!u8a$1(e))throw new Error("Uint8Array expected");let t="";for(let r=0;r<e.length;r++)t+=hexes$4[e[r]];return t}function numberToHexUnpadded$2(e){const t=e.toString(16);return 1&t.length?`0${t}`:t}function hexToNumber$2(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);return BigInt(""===e?"0":`0x${e}`)}function hexToBytes$4(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const r=new Uint8Array(t/2);for(let n=0;n<r.length;n++){const t=2*n,i=e.slice(t,t+2),o=Number.parseInt(i,16);if(Number.isNaN(o)||o<0)throw new Error("Invalid byte sequence");r[n]=o}return r}function bytesToNumberBE$1(e){return hexToNumber$2(bytesToHex$4(e))}function bytesToNumberLE$1(e){if(!u8a$1(e))throw new Error("Uint8Array expected");return hexToNumber$2(bytesToHex$4(Uint8Array.from(e).reverse()))}function numberToBytesBE$1(e,t){return hexToBytes$4(e.toString(16).padStart(2*t,"0"))}function numberToBytesLE$1(e,t){return numberToBytesBE$1(e,t).reverse()}function numberToVarBytesBE(e){return hexToBytes$4(numberToHexUnpadded$2(e))}function ensureBytes$1(e,t,r){let n;if("string"==typeof t)try{n=hexToBytes$4(t)}catch(o){throw new Error(`${e} must be valid hex string, got "${t}". Cause: ${o}`)}else{if(!u8a$1(t))throw new Error(`${e} must be hex string or Uint8Array`);n=Uint8Array.from(t)}const i=n.length;if("number"==typeof r&&i!==r)throw new Error(`${e} expected ${r} bytes, got ${i}`);return n}function concatBytes$2(...e){const t=new Uint8Array(e.reduce((e,t)=>e+t.length,0));let r=0;return e.forEach(e=>{if(!u8a$1(e))throw new Error("Uint8Array expected");t.set(e,r),r+=e.length}),t}function equalBytes$2(e,t){if(e.length!==t.length)return!1;for(let r=0;r<e.length;r++)if(e[r]!==t[r])return!1;return!0}function utf8ToBytes$1(e){if("string"!=typeof e)throw new Error("utf8ToBytes expected string, got "+typeof e);return new Uint8Array((new TextEncoder).encode(e))}function bitLen$1(e){let t;for(t=0;e>_0n$a;e>>=_1n$9,t+=1);return t}function bitGet(e,t){return e>>BigInt(t)&_1n$9}const bitSet=(e,t,r)=>e|(r?_1n$9:_0n$a)<<BigInt(t),bitMask$1=e=>(_2n$6<<BigInt(e-1))-_1n$9,u8n=e=>new Uint8Array(e),u8fr=e=>Uint8Array.from(e);function createHmacDrbg$1(e,t,r){if("number"!=typeof e||e<2)throw new Error("hashLen must be a number");if("number"!=typeof t||t<2)throw new Error("qByteLen must be a number");if("function"!=typeof r)throw new Error("hmacFn must be a function");let n=u8n(e),i=u8n(e),o=0;const s=()=>{n.fill(1),i.fill(0),o=0},a=(...e)=>r(i,n,...e),l=(e=u8n())=>{i=a(u8fr([0]),e),n=a(),0!==e.length&&(i=a(u8fr([1]),e),n=a())},c=()=>{if(o++>=1e3)throw new Error("drbg: tried 1000 values");let e=0;const r=[];for(;e<t;){n=a();const t=n.slice();r.push(t),e+=n.length}return concatBytes$2(...r)};return(e,t)=>{let r;for(s(),l(e);!(r=t(c()));)l();return s(),r}}const validatorFns={bigint:e=>"bigint"==typeof e,function:e=>"function"==typeof e,boolean:e=>"boolean"==typeof e,string:e=>"string"==typeof e,stringOrUint8Array:e=>"string"==typeof e||e instanceof Uint8Array,isSafeInteger:e=>Number.isSafeInteger(e),array:e=>Array.isArray(e),field:(e,t)=>t.Fp.isValid(e),hash:e=>"function"==typeof e&&Number.isSafeInteger(e.outputLen)};function validateObject$1(e,t,r={}){const n=(t,r,n)=>{const i=validatorFns[r];if("function"!=typeof i)throw new Error(`Invalid validator "${r}", expected function`);const o=e[t];if(!(n&&void 0===o||i(o,e)))throw new Error(`Invalid param ${String(t)}=${o} (${typeof o}), expected ${r}`)};for(const[i,o]of Object.entries(t))n(i,o,!1);for(const[i,o]of Object.entries(r))n(i,o,!0);return e}const ut=Object.freeze(Object.defineProperty({__proto__:null,bitGet:bitGet,bitLen:bitLen$1,bitMask:bitMask$1,bitSet:bitSet,bytesToHex:bytesToHex$4,bytesToNumberBE:bytesToNumberBE$1,bytesToNumberLE:bytesToNumberLE$1,concatBytes:concatBytes$2,createHmacDrbg:createHmacDrbg$1,ensureBytes:ensureBytes$1,equalBytes:equalBytes$2,hexToBytes:hexToBytes$4,hexToNumber:hexToNumber$2,numberToBytesBE:numberToBytesBE$1,numberToBytesLE:numberToBytesLE$1,numberToHexUnpadded:numberToHexUnpadded$2,numberToVarBytesBE:numberToVarBytesBE,utf8ToBytes:utf8ToBytes$1,validateObject:validateObject$1},Symbol.toStringTag,{value:"Module"})),_0n$9=BigInt(0),_1n$8=BigInt(1),_2n$5=BigInt(2),_3n$4=BigInt(3),_4n$2=BigInt(4),_5n$1=BigInt(5),_8n$2=BigInt(8);
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function mod$2(e,t){const r=e%t;return r>=_0n$9?r:t+r}function pow(e,t,r){if(r<=_0n$9||t<_0n$9)throw new Error("Expected power/modulo > 0");if(r===_1n$8)return _0n$9;let n=_1n$8;for(;t>_0n$9;)t&_1n$8&&(n=n*e%r),e=e*e%r,t>>=_1n$8;return n}function pow2$2(e,t,r){let n=e;for(;t-- >_0n$9;)n*=n,n%=r;return n}function invert$2(e,t){if(e===_0n$9||t<=_0n$9)throw new Error(`invert: expected positive integers, got n=${e} mod=${t}`);let r=mod$2(e,t),n=t,i=_0n$9,o=_1n$8;for(;r!==_0n$9;){const e=n%r,t=i-o*(n/r);n=r,r=e,i=o,o=t}if(n!==_1n$8)throw new Error("invert: does not exist");return mod$2(i,t)}function tonelliShanks$1(e){const t=(e-_1n$8)/_2n$5;let r,n,i;for(r=e-_1n$8,n=0;r%_2n$5===_0n$9;r/=_2n$5,n++);for(i=_2n$5;i<e&&pow(i,t,e)!==e-_1n$8;i++);if(1===n){const t=(e+_1n$8)/_4n$2;return function(e,r){const n=e.pow(r,t);if(!e.eql(e.sqr(n),r))throw new Error("Cannot find square root");return n}}const o=(r+_1n$8)/_2n$5;return function(e,s){if(e.pow(s,t)===e.neg(e.ONE))throw new Error("Cannot find square root");let a=n,l=e.pow(e.mul(e.ONE,i),r),c=e.pow(s,o),u=e.pow(s,r);for(;!e.eql(u,e.ONE);){if(e.eql(u,e.ZERO))return e.ZERO;let t=1;for(let n=e.sqr(u);t<a&&!e.eql(n,e.ONE);t++)n=e.sqr(n);const r=e.pow(l,_1n$8<<BigInt(a-t-1));l=e.sqr(r),c=e.mul(c,r),u=e.mul(u,l),a=t}return c}}function FpSqrt$1(e){if(e%_4n$2===_3n$4){const t=(e+_1n$8)/_4n$2;return function(e,r){const n=e.pow(r,t);if(!e.eql(e.sqr(n),r))throw new Error("Cannot find square root");return n}}if(e%_8n$2===_5n$1){const t=(e-_5n$1)/_8n$2;return function(e,r){const n=e.mul(r,_2n$5),i=e.pow(n,t),o=e.mul(r,i),s=e.mul(e.mul(o,_2n$5),i),a=e.mul(o,e.sub(s,e.ONE));if(!e.eql(e.sqr(a),r))throw new Error("Cannot find square root");return a}}return tonelliShanks$1(e)}BigInt(9),BigInt(16);const FIELD_FIELDS$1=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function validateField$1(e){const t=FIELD_FIELDS$1.reduce((e,t)=>(e[t]="function",e),{ORDER:"bigint",MASK:"bigint",BYTES:"isSafeInteger",BITS:"isSafeInteger"});return validateObject$1(e,t)}function FpPow$1(e,t,r){if(r<_0n$9)throw new Error("Expected power > 0");if(r===_0n$9)return e.ONE;if(r===_1n$8)return t;let n=e.ONE,i=t;for(;r>_0n$9;)r&_1n$8&&(n=e.mul(n,i)),i=e.sqr(i),r>>=_1n$8;return n}function FpInvertBatch$1(e,t){const r=new Array(t.length),n=t.reduce((t,n,i)=>e.is0(n)?t:(r[i]=t,e.mul(t,n)),e.ONE),i=e.inv(n);return t.reduceRight((t,n,i)=>e.is0(n)?t:(r[i]=e.mul(t,r[i]),e.mul(t,n)),i),r}function nLength$1(e,t){const r=void 0!==t?t:e.toString(2).length;return{nBitLength:r,nByteLength:Math.ceil(r/8)}}function Field$1(e,t,r=!1,n={}){if(e<=_0n$9)throw new Error(`Expected Field ORDER > 0, got ${e}`);const{nBitLength:i,nByteLength:o}=nLength$1(e,t);if(o>2048)throw new Error("Field lengths over 2048 bytes are not supported");const s=FpSqrt$1(e),a=Object.freeze({ORDER:e,BITS:i,BYTES:o,MASK:bitMask$1(i),ZERO:_0n$9,ONE:_1n$8,create:t=>mod$2(t,e),isValid:t=>{if("bigint"!=typeof t)throw new Error("Invalid field element: expected bigint, got "+typeof t);return _0n$9<=t&&t<e},is0:e=>e===_0n$9,isOdd:e=>(e&_1n$8)===_1n$8,neg:t=>mod$2(-t,e),eql:(e,t)=>e===t,sqr:t=>mod$2(t*t,e),add:(t,r)=>mod$2(t+r,e),sub:(t,r)=>mod$2(t-r,e),mul:(t,r)=>mod$2(t*r,e),pow:(e,t)=>FpPow$1(a,e,t),div:(t,r)=>mod$2(t*invert$2(r,e),e),sqrN:e=>e*e,addN:(e,t)=>e+t,subN:(e,t)=>e-t,mulN:(e,t)=>e*t,inv:t=>invert$2(t,e),sqrt:n.sqrt||(e=>s(a,e)),invertBatch:e=>FpInvertBatch$1(a,e),cmov:(e,t,r)=>r?t:e,toBytes:e=>r?numberToBytesLE$1(e,o):numberToBytesBE$1(e,o),fromBytes:e=>{if(e.length!==o)throw new Error(`Fp.fromBytes: expected ${o}, got ${e.length}`);return r?bytesToNumberLE$1(e):bytesToNumberBE$1(e)}});return Object.freeze(a)}function getFieldBytesLength$1(e){if("bigint"!=typeof e)throw new Error("field order must be bigint");const t=e.toString(2).length;return Math.ceil(t/8)}function getMinHashLength$1(e){const t=getFieldBytesLength$1(e);return t+Math.ceil(t/2)}function mapHashToField$1(e,t,r=!1){const n=e.length,i=getFieldBytesLength$1(t),o=getMinHashLength$1(t);if(n<16||n<o||n>1024)throw new Error(`expected ${o}-1024 bytes of input, got ${n}`);const s=mod$2(r?bytesToNumberBE$1(e):bytesToNumberLE$1(e),t-_1n$8)+_1n$8;return r?numberToBytesLE$1(s,i):numberToBytesBE$1(s,i)}
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$8=BigInt(0),_1n$7=BigInt(1);function wNAF$1(e,t){const r=(e,t)=>{const r=t.negate();return e?r:t},n=e=>({windows:Math.ceil(t/e)+1,windowSize:2**(e-1)});return{constTimeNegate:r,unsafeLadder(t,r){let n=e.ZERO,i=t;for(;r>_0n$8;)r&_1n$7&&(n=n.add(i)),i=i.double(),r>>=_1n$7;return n},precomputeWindow(e,t){const{windows:r,windowSize:i}=n(t),o=[];let s=e,a=s;for(let n=0;n<r;n++){a=s,o.push(a);for(let e=1;e<i;e++)a=a.add(s),o.push(a);s=a.double()}return o},wNAF(t,i,o){const{windows:s,windowSize:a}=n(t);let l=e.ZERO,c=e.BASE;const u=BigInt(2**t-1),h=2**t,d=BigInt(t);for(let e=0;e<s;e++){const t=e*a;let n=Number(o&u);o>>=d,n>a&&(n-=h,o+=_1n$7);const s=t,p=t+Math.abs(n)-1,f=e%2!=0,g=n<0;0===n?c=c.add(r(f,i[s])):l=l.add(r(g,i[p]))}return{p:l,f:c}},wNAFCached(e,t,r,n){const i=e._WINDOW_SIZE||1;let o=t.get(e);return o||(o=this.precomputeWindow(e,i),1!==i&&t.set(e,n(o))),this.wNAF(i,o,r)}}}function validateBasic(e){return validateField$1(e.Fp),validateObject$1(e,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...nLength$1(e.n,e.nBitLength),...e,p:e.Fp.ORDER})}
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function validatePointOpts(e){const t=validateBasic(e);validateObject$1(t,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});const{endo:r,Fp:n,a:i}=t;if(r){if(!n.eql(i,n.ZERO))throw new Error("Endomorphism can only be defined for Koblitz curves that have a=0");if("object"!=typeof r||"bigint"!=typeof r.beta||"function"!=typeof r.splitScalar)throw new Error("Expected endomorphism with beta: bigint and splitScalar: function")}return Object.freeze({...t})}const{bytesToNumberBE:b2n,hexToBytes:h2b}=ut,DER$1={Err:class extends Error{constructor(e=""){super(e)}},_parseInt(e){const{Err:t}=DER$1;if(e.length<2||2!==e[0])throw new t("Invalid signature integer tag");const r=e[1],n=e.subarray(2,r+2);if(!r||n.length!==r)throw new t("Invalid signature integer: wrong length");if(128&n[0])throw new t("Invalid signature integer: negative");if(0===n[0]&&!(128&n[1]))throw new t("Invalid signature integer: unnecessary leading zero");return{d:b2n(n),l:e.subarray(r+2)}},toSig(e){const{Err:t}=DER$1,r="string"==typeof e?h2b(e):e;if(!(r instanceof Uint8Array))throw new Error("ui8a expected");let n=r.length;if(n<2||48!=r[0])throw new t("Invalid signature tag");if(r[1]!==n-2)throw new t("Invalid signature: incorrect length");const{d:i,l:o}=DER$1._parseInt(r.subarray(2)),{d:s,l:a}=DER$1._parseInt(o);if(a.length)throw new t("Invalid signature: left bytes after parsing");return{r:i,s:s}},hexFromSig(e){const t=e=>8&Number.parseInt(e[0],16)?"00"+e:e,r=e=>{const t=e.toString(16);return 1&t.length?`0${t}`:t},n=t(r(e.s)),i=t(r(e.r)),o=n.length/2,s=i.length/2,a=r(o),l=r(s);return`30${r(s+o+4)}02${l}${i}02${a}${n}`}},_0n$7=BigInt(0),_1n$6=BigInt(1);BigInt(2);const _3n$3=BigInt(3);function weierstrassPoints(e){const t=validatePointOpts(e),{Fp:r}=t,n=t.toBytes||((e,t)=>{const n=t.toAffine();return concatBytes$2(Uint8Array.from([4]),r.toBytes(n.x),r.toBytes(n.y))}),i=t.fromBytes||(e=>{const t=e.subarray(1);return{x:r.fromBytes(t.subarray(0,r.BYTES)),y:r.fromBytes(t.subarray(r.BYTES,2*r.BYTES))}});function o(e){const{a:n,b:i}=t,o=r.sqr(e),s=r.mul(o,e);return r.add(r.add(s,r.mul(e,n)),i)}if(!r.eql(r.sqr(t.Gy),o(t.Gx)))throw new Error("bad generator point: equation left != right");function s(e){return"bigint"==typeof e&&_0n$7<e&&e<t.n}function a(e){if(!s(e))throw new Error("Expected valid bigint: 0 < bigint < curve.n")}function l(e){const{allowedPrivateKeyLengths:r,nByteLength:n,wrapPrivateKey:i,n:o}=t;if(r&&"bigint"!=typeof e){if(e instanceof Uint8Array&&(e=bytesToHex$4(e)),"string"!=typeof e||!r.includes(e.length))throw new Error("Invalid key");e=e.padStart(2*n,"0")}let s;try{s="bigint"==typeof e?e:bytesToNumberBE$1(ensureBytes$1("private key",e,n))}catch(error){throw new Error(`private key must be ${n} bytes, hex or bigint, not ${typeof e}`)}return i&&(s=mod$2(s,o)),a(s),s}const c=new Map;function u(e){if(!(e instanceof h))throw new Error("ProjectivePoint expected")}class h{constructor(e,t,n){if(this.px=e,this.py=t,this.pz=n,null==e||!r.isValid(e))throw new Error("x required");if(null==t||!r.isValid(t))throw new Error("y required");if(null==n||!r.isValid(n))throw new Error("z required")}static fromAffine(e){const{x:t,y:n}=e||{};if(!e||!r.isValid(t)||!r.isValid(n))throw new Error("invalid affine point");if(e instanceof h)throw new Error("projective point not allowed");const i=e=>r.eql(e,r.ZERO);return i(t)&&i(n)?h.ZERO:new h(t,n,r.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(e){const t=r.invertBatch(e.map(e=>e.pz));return e.map((e,r)=>e.toAffine(t[r])).map(h.fromAffine)}static fromHex(e){const t=h.fromAffine(i(ensureBytes$1("pointHex",e)));return t.assertValidity(),t}static fromPrivateKey(e){return h.BASE.multiply(l(e))}_setWindowSize(e){this._WINDOW_SIZE=e,c.delete(this)}assertValidity(){if(this.is0()){if(t.allowInfinityPoint&&!r.is0(this.py))return;throw new Error("bad point: ZERO")}const{x:e,y:n}=this.toAffine();if(!r.isValid(e)||!r.isValid(n))throw new Error("bad point: x or y not FE");const i=r.sqr(n),s=o(e);if(!r.eql(i,s))throw new Error("bad point: equation left != right");if(!this.isTorsionFree())throw new Error("bad point: not in prime-order subgroup")}hasEvenY(){const{y:e}=this.toAffine();if(r.isOdd)return!r.isOdd(e);throw new Error("Field doesn't support isOdd")}equals(e){u(e);const{px:t,py:n,pz:i}=this,{px:o,py:s,pz:a}=e,l=r.eql(r.mul(t,a),r.mul(o,i)),c=r.eql(r.mul(n,a),r.mul(s,i));return l&&c}negate(){return new h(this.px,r.neg(this.py),this.pz)}double(){const{a:e,b:n}=t,i=r.mul(n,_3n$3),{px:o,py:s,pz:a}=this;let l=r.ZERO,c=r.ZERO,u=r.ZERO,d=r.mul(o,o),p=r.mul(s,s),f=r.mul(a,a),g=r.mul(o,s);return g=r.add(g,g),u=r.mul(o,a),u=r.add(u,u),l=r.mul(e,u),c=r.mul(i,f),c=r.add(l,c),l=r.sub(p,c),c=r.add(p,c),c=r.mul(l,c),l=r.mul(g,l),u=r.mul(i,u),f=r.mul(e,f),g=r.sub(d,f),g=r.mul(e,g)Line truncated
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||||
|
function getHash(e){return{hash:e,hmac:(t,...r)=>hmac$2(e,t,concatBytes$3(...r)),randomBytes:randomBytes$2}}function createCurve(e,t){const r=t=>weierstrass$2({...e,...getHash(t)});return Object.freeze({...r(t),create:r})}
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */hmac$2.create=(e,t)=>new HMAC$1(e,t);const secp256k1P=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),secp256k1N=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),_1n$5=BigInt(1),_2n$4=BigInt(2),divNearest$2=(e,t)=>(e+t/_2n$4)/t;function sqrtMod$2(e){const t=secp256k1P,r=BigInt(3),n=BigInt(6),i=BigInt(11),o=BigInt(22),s=BigInt(23),a=BigInt(44),l=BigInt(88),c=e*e*e%t,u=c*c*e%t,h=pow2$2(u,r,t)*u%t,d=pow2$2(h,r,t)*u%t,p=pow2$2(d,_2n$4,t)*c%t,f=pow2$2(p,i,t)*p%t,g=pow2$2(f,o,t)*f%t,m=pow2$2(g,a,t)*g%t,y=pow2$2(m,l,t)*m%t,b=pow2$2(y,a,t)*g%t,v=pow2$2(b,r,t)*u%t,_=pow2$2(v,s,t)*f%t,E=pow2$2(_,n,t)*c%t,w=pow2$2(E,_2n$4,t);if(!Fp.eql(Fp.sqr(w),e))throw new Error("Cannot find square root");return w}const Fp=Field$1(secp256k1P,void 0,void 0,{sqrt:sqrtMod$2}),secp256k1$1=createCurve({a:BigInt(0),b:BigInt(7),Fp:Fp,n:secp256k1N,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:!0,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:e=>{const t=secp256k1N,r=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),n=-_1n$5*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),i=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),o=r,s=BigInt("0x100000000000000000000000000000000"),a=divNearest$2(o*e,t),l=divNearest$2(-n*e,t);let c=mod$2(e-a*r-l*i,t),u=mod$2(-a*n-l*o,t);const h=c>s,d=u>s;if(h&&(c=t-c),d&&(u=t-u),c>s||u>s)throw new Error("splitScalar: Endomorphism failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}}},sha256$3),_0n$6=BigInt(0),fe=e=>"bigint"==typeof e&&_0n$6<e&&e<secp256k1P,ge=e=>"bigint"==typeof e&&_0n$6<e&&e<secp256k1N,TAGGED_HASH_PREFIXES$1={};function taggedHash$1(e,...t){let r=TAGGED_HASH_PREFIXES$1[e];if(void 0===r){const t=sha256$3(Uint8Array.from(e,e=>e.charCodeAt(0)));r=concatBytes$2(t,t),TAGGED_HASH_PREFIXES$1[e]=r}return sha256$3(concatBytes$2(r,...t))}const pointToBytes$1=e=>e.toRawBytes(!0).slice(1),numTo32b=e=>numberToBytesBE$1(e,32),modP=e=>mod$2(e,secp256k1P),modN=e=>mod$2(e,secp256k1N),Point$2=secp256k1$1.ProjectivePoint,GmulAdd=(e,t,r)=>Point$2.BASE.multiplyAndAddUnsafe(e,t,r);function schnorrGetExtPubKey$1(e){let t=secp256k1$1.utils.normPrivateKeyToScalar(e),r=Point$2.fromPrivateKey(t);return{scalar:r.hasEvenY()?t:modN(-t),bytes:pointToBytes$1(r)}}function lift_x$1(e){if(!fe(e))throw new Error("bad x: need 0 < x < p");const t=modP(e*e);let r=sqrtMod$2(modP(t*e+BigInt(7)));r%_2n$4!==_0n$6&&(r=modP(-r));const n=new Point$2(e,r,_1n$5);return n.assertValidity(),n}function challenge$1(...e){return modN(bytesToNumberBE$1(taggedHash$1("BIP0340/challenge",...e)))}function schnorrGetPublicKey$1(e){return schnorrGetExtPubKey$1(e).bytes}function schnorrSign$1(e,t,r=randomBytes$2(32)){const n=ensureBytes$1("message",e),{bytes:i,scalar:o}=schnorrGetExtPubKey$1(t),s=ensureBytes$1("auxRand",r,32),a=numTo32b(o^bytesToNumberBE$1(taggedHash$1("BIP0340/aux",s))),l=taggedHash$1("BIP0340/nonce",a,i,n),c=modN(bytesToNumberBE$1(l));if(c===_0n$6)throw new Error("sign failed: k is zero");const{bytes:u,scalar:h}=schnorrGetExtPubKey$1(c),d=challenge$1(u,i,n),p=new Uint8Array(64);if(p.set(u,0),p.set(numTo32b(modN(h+d*o)),32),!schnorrVerify$1(p,n,i))throw new Error("sign: Invalid signature produced");return p}function schnorrVerify$1(e,t,r){const n=ensureBytes$1("signature",e,64),i=ensureBytes$1("message",t),o=ensureBytes$1("publicKey",r,32);try{const e=lift_x$1(bytesToNumberBE$1(o)),t=bytesToNumberBE$1(n.subarray(0,32));if(!fe(t))return!1;const r=bytesToNumberBE$1(n.subarray(32,64));if(!ge(r))return!1;const s=challenge$1(numTo32b(t),pointToBytes$1(e),i),a=GmulAdd(e,r,modN(-s));return!(!a||!a.hasEvenY()||a.toAffine().x!==t)}catch(error){return!1}}const schnorr$1=(()=>({getPublicKey:schnorrGetPublicKey$1,sign:schnorrSign$1,verify:schnorrVerify$1,utils:{randomPrivateKey:secp256k1$1.utils.randomPrivateKey,lift_x:lift_x$1,pointToBytes:pointToBytes$1,numberToBytesBE:numberToBytesBE$1,bytesToNumberBE:bytesToNumberBE$1,taggedHash:taggedHash$1,mod:mod$2}}))(),crypto$1="object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0,u8a=e=>e instanceof Uint8Array,createView$2=e=>new DataView(e.buffer,e.byteOffset,e.byteLength),rotr$2=(e,t)=>e<<32-t|e>>>t,isLE$2=68===new Uint8Array(new Uint32Array([287454020]).buffer)[0];if(!isLE$2)throw new Error("Non little-endian hardware is not supported");const hexes$3=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex$3(e){if(!u8a(e))throw new Error("Uint8Array expected");let t="";for(let r=0;r<e.length;r++)t+=hexes$3[e[r]];return t}function hexToBytes$3(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const r=new Uint8Array(t/2);Line truncated
|
||||||
|
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||||
|
function assertNumber(e){if(!Number.isSafeInteger(e))throw new Error(`Wrong integer: ${e}`)}function chain$1(...e){const t=(e,t)=>r=>e(t(r)),r=Array.from(e).reverse().reduce((e,r)=>e?t(e,r.encode):r.encode,void 0),n=e.reduce((e,r)=>e?t(e,r.decode):r.decode,void 0);return{encode:r,decode:n}}function alphabet$1(e){return{encode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("alphabet.encode input should be an array of numbers");return t.map(t=>{if(assertNumber(t),t<0||t>=e.length)throw new Error(`Digit index outside alphabet: ${t} (alphabet: ${e.length})`);return e[t]})},decode:t=>{if(!Array.isArray(t)||t.length&&"string"!=typeof t[0])throw new Error("alphabet.decode input should be array of strings");return t.map(t=>{if("string"!=typeof t)throw new Error(`alphabet.decode: not string element=${t}`);const r=e.indexOf(t);if(-1===r)throw new Error(`Unknown letter: "${t}". Allowed: ${e}`);return r})}}}function join$1(e=""){if("string"!=typeof e)throw new Error("join separator should be string");return{encode:t=>{if(!Array.isArray(t)||t.length&&"string"!=typeof t[0])throw new Error("join.encode input should be array of strings");for(let e of t)if("string"!=typeof e)throw new Error(`join.encode: non-string input=${e}`);return t.join(e)},decode:t=>{if("string"!=typeof t)throw new Error("join.decode input should be string");return t.split(e)}}}function padding$1(e,t="="){if(assertNumber(e),"string"!=typeof t)throw new Error("padding chr should be string");return{encode(r){if(!Array.isArray(r)||r.length&&"string"!=typeof r[0])throw new Error("padding.encode input should be array of strings");for(let e of r)if("string"!=typeof e)throw new Error(`padding.encode: non-string input=${e}`);for(;r.length*e%8;)r.push(t);return r},decode(r){if(!Array.isArray(r)||r.length&&"string"!=typeof r[0])throw new Error("padding.encode input should be array of strings");for(let e of r)if("string"!=typeof e)throw new Error(`padding.decode: non-string input=${e}`);let n=r.length;if(n*e%8)throw new Error("Invalid padding: string should have whole number of bytes");for(;n>0&&r[n-1]===t;n--)if(!((n-1)*e%8))throw new Error("Invalid padding: string has too much padding");return r.slice(0,n)}}}function normalize(e){if("function"!=typeof e)throw new Error("normalize fn should be function");return{encode:e=>e,decode:t=>e(t)}}function convertRadix(e,t,r){if(t<2)throw new Error(`convertRadix: wrong from=${t}, base cannot be less than 2`);if(r<2)throw new Error(`convertRadix: wrong to=${r}, base cannot be less than 2`);if(!Array.isArray(e))throw new Error("convertRadix: data should be array");if(!e.length)return[];let n=0;const i=[],o=Array.from(e);for(o.forEach(e=>{if(assertNumber(e),e<0||e>=t)throw new Error(`Wrong integer: ${e}`)});;){let e=0,s=!0;for(let i=n;i<o.length;i++){const a=o[i],l=t*e+a;if(!Number.isSafeInteger(l)||t*e/t!==e||l-a!==t*e)throw new Error("convertRadix: carry overflow");if(e=l%r,o[i]=Math.floor(l/r),!Number.isSafeInteger(o[i])||o[i]*r+e!==l)throw new Error("convertRadix: carry overflow");s&&(o[i]?s=!1:n=i)}if(i.push(e),s)break}for(let s=0;s<e.length-1&&0===e[s];s++)i.push(0);return i.reverse()}wrapConstructor(()=>new SHA224);const gcd$1=(e,t)=>t?gcd$1(t,e%t):e,radix2carry$1=(e,t)=>e+(t-gcd$1(e,t));function convertRadix2$1(e,t,r,n){if(!Array.isArray(e))throw new Error("convertRadix2: data should be array");if(t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(r<=0||r>32)throw new Error(`convertRadix2: wrong to=${r}`);if(radix2carry$1(t,r)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${r} carryBits=${radix2carry$1(t,r)}`);let i=0,o=0;const s=2**r-1,a=[];for(const l of e){if(assertNumber(l),l>=2**t)throw new Error(`convertRadix2: invalid data word=${l} from=${t}`);if(i=i<<t|l,o+t>32)throw new Error(`convertRadix2: carry overflow pos=${o} from=${t}`);for(o+=t;o>=r;o-=r)a.push((i>>o-r&s)>>>0);i&=2**o-1}if(i=i<<r-o&s,!n&&o>=t)throw new Error("Excess padding");if(!n&&i)throw new Error(`Non-zero padding: ${i}`);return n&&o>0&&a.push(i>>>0),a}function radix(e){return assertNumber(e),{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix.encode input should be Uint8Array");return convertRadix(Array.from(t),256,e)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix.decode input should be array of strings");return Uint8Array.from(convertRadix(t,e,256))}}}function radix2$1(e,t=!1){if(assertNumber(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(radix2carry$1(8,e)>32||radix2carry$1(e,8)>32)throw new Error("radix2: carry overflow");return{encode:r=>{if(!(r instanceof Uint8Array))throw new Error("radix2.encode input should be Uint8Array");return convertRadix2$1(Array.from(r),8,e,!t)},decode:r=>{if(!Array.isArray(r)||r.length&&"number"!=typeof r[0])throw new Error("radix2.decode input should be array of strings");return Uint8Array.from(convertRadix2$1(r,e,8,t))}}}function unsafeWrapper$1(e){if("function"!=typeof e)thrLine truncated
|
||||||
|
/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */Object.keys(CODERS).join(", ");const u32$1=e=>new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4)),isLE$1=68===new Uint8Array(new Uint32Array([287454020]).buffer)[0];if(!isLE$1)throw new Error("Non little-endian hardware is not supported");const hexes$2=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex$2(e){bytes(e);let t="";for(let r=0;r<e.length;r++)t+=hexes$2[e[r]];return t}const asciis$2={_0:48,_9:57,_A:65,_F:70,_a:97,_f:102};function asciiToBase16$2(e){return e>=asciis$2._0&&e<=asciis$2._9?e-asciis$2._0:e>=asciis$2._A&&e<=asciis$2._F?e-(asciis$2._A-10):e>=asciis$2._a&&e<=asciis$2._f?e-(asciis$2._a-10):void 0}function hexToBytes$2(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);const t=e.length,r=t/2;if(t%2)throw new Error("padded hex string expected, got unpadded hex of length "+t);const n=new Uint8Array(r);for(let i=0,o=0;i<r;i++,o+=2){const t=asciiToBase16$2(e.charCodeAt(o)),r=asciiToBase16$2(e.charCodeAt(o+1));if(void 0===t||void 0===r){const t=e[o]+e[o+1];throw new Error('hex string expected, got non-hex character "'+t+'" at index '+o)}n[i]=16*t+r}return n}function checkOpts$1(e,t){if(null==t||"object"!=typeof t)throw new Error("options must be defined");return Object.assign(e,t)}function equalBytes$1(e,t){if(e.length!==t.length)return!1;let r=0;for(let n=0;n<e.length;n++)r|=e[n]^t[n];return 0===r}const wrapCipher$1=(e,t)=>(Object.assign(t,e),t),BLOCK_SIZE$1=16,POLY$1=283;function mul2$1(e){return e<<1^POLY$1&-(e>>7)}function mul$1(e,t){let r=0;for(;t>0;t>>=1)r^=e&-(1&t),e=mul2$1(e);return r}const sbox$1=(()=>{let e=new Uint8Array(256);for(let r=0,n=1;r<256;r++,n^=mul2$1(n))e[r]=n;const t=new Uint8Array(256);t[0]=99;for(let r=0;r<255;r++){let n=e[255-r];n|=n<<8,t[e[r]]=255&(n^n>>4^n>>5^n>>6^n>>7^99)}return t})(),invSbox$1=sbox$1.map((e,t)=>sbox$1.indexOf(t)),rotr32_8$1=e=>e<<24|e>>>8,rotl32_8$1=e=>e<<8|e>>>24;function genTtable$1(e,t){if(256!==e.length)throw new Error("Wrong sbox length");const r=new Uint32Array(256).map((r,n)=>t(e[n])),n=r.map(rotl32_8$1),i=n.map(rotl32_8$1),o=i.map(rotl32_8$1),s=new Uint32Array(65536),a=new Uint32Array(65536),l=new Uint16Array(65536);for(let c=0;c<256;c++)for(let t=0;t<256;t++){const u=256*c+t;s[u]=r[c]^n[t],a[u]=i[c]^o[t],l[u]=e[c]<<8|e[t]}return{sbox:e,sbox2:l,T0:r,T1:n,T2:i,T3:o,T01:s,T23:a}}const tableEncoding$1=genTtable$1(sbox$1,e=>mul$1(e,3)<<24|e<<16|e<<8|mul$1(e,2)),tableDecoding$1=genTtable$1(invSbox$1,e=>mul$1(e,11)<<24|mul$1(e,13)<<16|mul$1(e,9)<<8|mul$1(e,14)),xPowers$1=(()=>{const e=new Uint8Array(16);for(let t=0,r=1;t<16;t++,r=mul2$1(r))e[t]=r;return e})();function expandKeyLE$1(e){bytes(e);const t=e.length;if(![16,24,32].includes(t))throw new Error(`aes: wrong key size: should be 16, 24 or 32, got: ${t}`);const{sbox2:r}=tableEncoding$1,n=u32$1(e),i=n.length,o=e=>applySbox$1(r,e,e,e,e),s=new Uint32Array(t+28);s.set(n);for(let a=i;a<s.length;a++){let e=s[a-1];a%i===0?e=o(rotr32_8$1(e))^xPowers$1[a/i-1]:i>6&&a%i===4&&(e=o(e)),s[a]=s[a-i]^e}return s}function expandKeyDecLE$1(e){const t=expandKeyLE$1(e),r=t.slice(),n=t.length,{sbox2:i}=tableEncoding$1,{T0:o,T1:s,T2:a,T3:l}=tableDecoding$1;for(let c=0;c<n;c+=4)for(let e=0;e<4;e++)r[c+e]=t[n-c-4+e];t.fill(0);for(let c=4;c<n-4;c++){const e=r[c],t=applySbox$1(i,e,e,e,e);r[c]=o[255&t]^s[t>>>8&255]^a[t>>>16&255]^l[t>>>24]}return r}function apply0123$1(e,t,r,n,i,o){return e[r<<8&65280|n>>>8&255]^t[i>>>8&65280|o>>>24&255]}function applySbox$1(e,t,r,n,i){return e[255&t|65280&r]|e[n>>>16&255|i>>>16&65280]<<16}function encrypt$4(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableEncoding$1;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123$1(s,a,t,r,n,i),c=e[l++]^apply0123$1(s,a,r,n,i,t),u=e[l++]^apply0123$1(s,a,n,i,t,r),h=e[l++]^apply0123$1(s,a,i,t,r,n);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox$1(o,t,r,n,i),s1:e[l++]^applySbox$1(o,r,n,i,t),s2:e[l++]^applySbox$1(o,n,i,t,r),s3:e[l++]^applySbox$1(o,i,t,r,n)}}function decrypt$4(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableDecoding$1;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123$1(s,a,t,i,n,r),c=e[l++]^apply0123$1(s,a,r,t,i,n),u=e[l++]^apply0123$1(s,a,n,r,t,i),h=e[l++]^apply0123$1(s,a,i,n,r,t);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox$1(o,t,i,n,r),s1:e[l++]^applySbox$1(o,r,t,i,n),s2:e[l++]^applySbox$1(o,n,r,t,i),s3:e[l++]^applySbox$1(o,i,n,r,t)}}function getDst(e,t){if(!t)return new Uint8Array(e);if(bytes(t),t.length<e)throw new Error(`aes: wrong destination length, expected at least ${e}, got: ${t.length}`);return t}function validateBlockDecrypt$1(e){if(bytes(e),e.length%BLOCK_SIZE$1!==0)throw new Error(`aes/(cbc-ecb).decrypt ciphertext should consist of blocks with size ${BLOCK_SIZE$1}`)}function validateBlockEncrypt$1(e,t,r){let n=e.length;const i=n%BLOCK_SIZE$1;if(!t&&0!==i)throw new Error("aecLine truncated
|
||||||
|
/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */const _0n$5=BigInt(0),_1n$4=BigInt(1),_2n$3=BigInt(2),_3n$2=BigInt(3),_8n$1=BigInt(8),CURVE=Object.freeze({a:_0n$5,b:BigInt(7),P:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:_1n$4,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee")}),divNearest$1=(e,t)=>(e+t/_2n$3)/t,endo={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar(e){const{n:t}=CURVE,r=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),n=-_1n$4*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),i=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),o=r,s=BigInt("0x100000000000000000000000000000000"),a=divNearest$1(o*e,t),l=divNearest$1(-n*e,t);let c=mod$1(e-a*r-l*i,t),u=mod$1(-a*n-l*o,t);const h=c>s,d=u>s;if(h&&(c=t-c),d&&(u=t-u),c>s||u>s)throw new Error("splitScalarEndo: Endomorphism failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}},fieldLen=32,groupLen=32,compressedLen=fieldLen+1,uncompressedLen=2*fieldLen+1;function weierstrass$1(e){const{a:t,b:r}=CURVE,n=mod$1(e*e),i=mod$1(n*e);return mod$1(i+t*e+r)}const USE_ENDOMORPHISM=CURVE.a===_0n$5;function assertJacPoint(e){if(!(e instanceof JacobianPoint))throw new TypeError("JacobianPoint expected")}class JacobianPoint{constructor(e,t,r){this.x=e,this.y=t,this.z=r}static fromAffine(e){if(!(e instanceof Point$1))throw new TypeError("JacobianPoint#fromAffine: expected Point");return e.equals(Point$1.ZERO)?JacobianPoint.ZERO:new JacobianPoint(e.x,e.y,_1n$4)}static toAffineBatch(e){const t=invertBatch(e.map(e=>e.z));return e.map((e,r)=>e.toAffine(t[r]))}static normalizeZ(e){return JacobianPoint.toAffineBatch(e).map(JacobianPoint.fromAffine)}equals(e){assertJacPoint(e);const{x:t,y:r,z:n}=this,{x:i,y:o,z:s}=e,a=mod$1(n*n),l=mod$1(s*s),c=mod$1(t*l),u=mod$1(i*a),h=mod$1(mod$1(r*s)*l),d=mod$1(mod$1(o*n)*a);return c===u&&h===d}negate(){return new JacobianPoint(this.x,mod$1(-this.y),this.z)}double(){const{x:e,y:t,z:r}=this,n=mod$1(e*e),i=mod$1(t*t),o=mod$1(i*i),s=e+i,a=mod$1(_2n$3*(mod$1(s*s)-n-o)),l=mod$1(_3n$2*n),c=mod$1(l*l),u=mod$1(c-_2n$3*a),h=mod$1(l*(a-u)-_8n$1*o),d=mod$1(_2n$3*t*r);return new JacobianPoint(u,h,d)}add(e){assertJacPoint(e);const{x:t,y:r,z:n}=this,{x:i,y:o,z:s}=e;if(i===_0n$5||o===_0n$5)return this;if(t===_0n$5||r===_0n$5)return e;const a=mod$1(n*n),l=mod$1(s*s),c=mod$1(t*l),u=mod$1(i*a),h=mod$1(mod$1(r*s)*l),d=mod$1(mod$1(o*n)*a),p=mod$1(u-c),f=mod$1(d-h);if(p===_0n$5)return f===_0n$5?this.double():JacobianPoint.ZERO;const g=mod$1(p*p),m=mod$1(p*g),y=mod$1(c*g),b=mod$1(f*f-m-_2n$3*y),v=mod$1(f*(y-b)-h*m),_=mod$1(n*s*p);return new JacobianPoint(b,v,_)}subtract(e){return this.add(e.negate())}multiplyUnsafe(e){const t=JacobianPoint.ZERO;if("bigint"==typeof e&&e===_0n$5)return t;let r=normalizeScalar(e);if(r===_1n$4)return this;if(!USE_ENDOMORPHISM){let e=t,n=this;for(;r>_0n$5;)r&_1n$4&&(e=e.add(n)),n=n.double(),r>>=_1n$4;return e}let{k1neg:n,k1:i,k2neg:o,k2:s}=endo.splitScalar(r),a=t,l=t,c=this;for(;i>_0n$5||s>_0n$5;)i&_1n$4&&(a=a.add(c)),s&_1n$4&&(l=l.add(c)),c=c.double(),i>>=_1n$4,s>>=_1n$4;return n&&(a=a.negate()),o&&(l=l.negate()),l=new JacobianPoint(mod$1(l.x*endo.beta),l.y,l.z),a.add(l)}precomputeWindow(e){const t=USE_ENDOMORPHISM?128/e+1:256/e+1,r=[];let n=this,i=n;for(let o=0;o<t;o++){i=n,r.push(i);for(let t=1;t<2**(e-1);t++)i=i.add(n),r.push(i);n=i.double()}return r}wNAF(e,t){!t&&this.equals(JacobianPoint.BASE)&&(t=Point$1.BASE);const r=t&&t._WINDOW_SIZE||1;if(256%r)throw new Error("Point#wNAF: Invalid precomputation window, must be power of 2");let n=t&&pointPrecomputes$1.get(t);n||(n=this.precomputeWindow(r),t&&1!==r&&(n=JacobianPoint.normalizeZ(n),pointPrecomputes$1.set(t,n)));let i=JacobianPoint.ZERO,o=JacobianPoint.BASE;const s=1+(USE_ENDOMORPHISM?128/r:256/r),a=2**(r-1),l=BigInt(2**r-1),c=2**r,u=BigInt(r);for(let h=0;h<s;h++){const t=h*a;let r=Number(e&l);e>>=u,r>a&&(r-=c,e+=_1n$4);const s=t,d=t+Math.abs(r)-1,p=h%2!=0,f=r<0;0===r?o=o.add(constTimeNegate(p,n[s])):i=i.add(constTimeNegate(f,n[d]))}return{p:i,f:o}}multiply(e,t){let r,n,i=normalizeScalar(e);if(USE_ENDOMORPHISM){const{k1neg:e,k1:o,k2neg:s,k2:a}=endo.splitScalar(i);let{p:l,f:c}=this.wNAF(o,t),{p:u,f:h}=this.wNAF(a,t);l=constTimeNegate(e,l),u=constTimeNegate(s,u),u=new JacobianPoint(mod$1(u.x*endo.beta),u.y,u.z),r=l.add(u),n=c.add(h)}else{const{p:e,f:o}=this.wNAF(i,t);r=e,n=o}return JacobianPoint.normalizeZ([r,n])[0]}toAffine(e){const{x:t,y:r,z:n}=this,i=this.equals(JacobianPoint.ZERO);null==e&&(e=i?_8n$1:invert$1(n));const o=e,s=mod$1(o*o),a=mod$1(s*o),l=mod$1(t*s),c=mod$1(r*a),u=mod$1(n*o);if(i)return Point$1.ZERO;if(u!==_1n$4)throw new Error("invZ was invalid");return new Point$1(l,c)}}function constTLine truncated
|
||||||
|
/*! For license information please see remotestorage.js.LICENSE.txt */function requireRemotestorage(){return hasRequiredRemotestorage||(hasRequiredRemotestorage=1,function(module,exports$1){var t;t=function(){return function(){var __webpack_modules__={548:function(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Access=void 0;class r{constructor(e){this.rs=e,this.reset(!1)}get scopes(){return Object.keys(this.scopeModeMap).map(e=>({name:e,mode:this.scopeModeMap[e]}))}get scopeParameter(){return this.scopes.map(e=>`${this._scopeNameForParameter(e)}:${e.mode}`).join(" ")}claim(e,t){if("string"!=typeof e||-1!==e.indexOf("/")||0===e.length)throw new Error("Scope should be a non-empty string without forward slashes");if("string"!=typeof t||!t.match(/^rw?$/))throw new Error("Mode should be either 'r' or 'rw'");this._adjustRootPaths(e),this.scopeModeMap[e]=t,this._notifyChange()}get(e){return this.scopeModeMap[e]}remove(e){const t={};for(const r in this.scopeModeMap)t[r]=this.scopeModeMap[r];this.reset(!1),delete t[e];for(const r in t)this._adjustRootPaths(r),this.scopeModeMap[r]=t[r];this._notifyChange()}checkPermission(e,t){const r=this.get(e);return r&&("r"===t||"rw"===r)}checkPathPermission(e,t){if(this.checkPermission("*",t))return!0;const r=this._getModuleName(e);return!!this.checkPermission(r,t)}reset(e=!0){this.rootPaths=[],this.scopeModeMap={},e&&this._notifyChange()}_getModuleName(e){if("/"!==e[0])throw new Error("Path should start with a slash");const t=e.replace(/^\/public/,"").match(/^\/([^/]*)\//);return t?t[1]:"*"}_adjustRootPaths(e){"*"in this.scopeModeMap||"*"===e?this.rootPaths=["/"]:e in this.scopeModeMap||(this.rootPaths.push("/"+e+"/"),this.rootPaths.push("/public/"+e+"/"))}_scopeNameForParameter(e){if("*"===e.name&&this.storageType){if("2012.04"===this.storageType)return"";if(this.storageType.match(/remotestorage-0[01]/))return"root"}return e.name}setStorageType(e){this.storageType=e}_notifyChange(){this.rs&&"function"==typeof this.rs._checkScopeChange&&this.rs._checkScopeChange()}static _rs_init(){}}t.Access=r,t.default=r},275:function(e,t,r){var n=this&&this.__awaiter||function(e,t,r,n){return new(r||(r=Promise))(function(i,o){function s(e){try{l(n.next(e))}catch(e){o(e)}}function a(e){try{l(n.throw(e))}catch(e){o(e)}}function l(e){var t;e.done?i(e.value):(t=e.value,t instanceof r?t:new r(function(e){e(t)})).then(s,a)}l((n=n.apply(e,t||[])).next())})},i=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.Authorize=void 0;const o=i(r(28)),s=i(r(334)),a=r(746),l=i(r(503)),c=r(368);let u;function h(e){const t=e||d.getLocation().href,r={};for(const[o,s]of new URL(t).searchParams)r[o]=s;const n=t.indexOf("#");if(-1===n)return r;const i=t.substring(n+1);return i.includes("=")?i.split("&").reduce(function(e,t){const r=t.split("=");if("state"===r[0]&&r[1].match(/rsDiscovery/)){let t=decodeURIComponent(r[1]);const n=t.substr(t.indexOf("rsDiscovery=")).split("&")[0].split("=")[1];e.rsDiscovery=JSON.parse(atob(n)),t=t.replace(new RegExp("&?rsDiscovery="+n),""),t.length>0&&(e.state=t)}else e[decodeURIComponent(r[0])]=decodeURIComponent(r[1]);return e},r):r}class d{static authorize(e,t){if((0,s.default)("[Authorize] authURL = ",t.authURL,"scope = ",t.scope,"redirectUri = ",t.redirectUri,"clientId = ",t.clientId,"response_type =",t.response_type),!t.scope)throw new Error("Cannot authorize due to undefined or empty scope; did you forget to access.claim()?");if("function"==typeof e._rememberPendingScope&&e._rememberPendingScope(t.scope),!(0,a.localStorageAvailable)()&&"remotestorage"===e.backend){t.redirectUri+=t.redirectUri.indexOf("#")>0?"&":"#";const r={userAddress:e.remote.userAddress,href:e.remote.href,storageApi:e.remote.storageApi,properties:e.remote.properties};t.redirectUri+="rsDiscovery="+(0,a.toBase64)(JSON.stringify(r))}const r=function(e){const t=new URL(e.redirectUri);e.state||(e.state=t.hash?t.hash.substring(1):""),e.response_type||(e.response_type="token");const r=new URL(e.authURL);r.searchParams.set("redirect_uri",e.redirectUri.replace(/#.*$/,"")),r.searchParams.set("scope",e.scope),r.searchParams.set("client_id",e.clientId);for(const n of["state","response_type","code_challenge","code_challenge_method","token_access_type"]){const t=e[n];t&&r.searchParams.set(n,t)}return r.href}(t);a.globalContext.cordova&&(d.getLocation().href.startsWith("file:")||"string"==typeof o.default.cordovaRedirectUri&&t.redirectUri===o.default.cordovaRedirectUri)?d.openWindow(r,t.redirectUri,"location=yes,clearsessioncache=yes,clearcache=yes").then(r=>{e.remote.configure({token:r.access_token}),"function"==typeof e._completeAuthorization&&e._completeAuthorization(r.scope||t.scope)}):d.setLocation(r)}static refreshAccessToken(e,t,r){return n(this,void 0,void 0,function*(){var e,n,i;yield t.configure({token:null,tokenType:null});const o=new URLSearchParams({grant_type:"refresh_token",client_id:t.clientId,refresh_token:r}),a=yield(Line truncated
|
||||||
|
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||||
|
Object.defineProperty(e,"__esModule",{value:!0}),e.bytes=e.stringToBytes=e.str=e.bytesToString=e.hex=e.utf8=e.bech32m=e.bech32=e.base58check=e.base58xmr=e.base58xrp=e.base58flickr=e.base58=e.base64url=e.base64=e.base32crockford=e.base32hex=e.base32=e.base16=e.utils=e.assertNumber=void 0,e.assertNumber=t;const l=(e,t)=>t?l(t,e%t):e,c=(e,t)=>e+(t-l(e,t));function u(e,r,n,i){if(!Array.isArray(e))throw new Error("convertRadix2: data should be array");if(r<=0||r>32)throw new Error(`convertRadix2: wrong from=${r}`);if(n<=0||n>32)throw new Error(`convertRadix2: wrong to=${n}`);if(c(r,n)>32)throw new Error(`convertRadix2: carry overflow from=${r} to=${n} carryBits=${c(r,n)}`);let o=0,s=0;const a=2**n-1,l=[];for(const c of e){if(t(c),c>=2**r)throw new Error(`convertRadix2: invalid data word=${c} from=${r}`);if(o=o<<r|c,s+r>32)throw new Error(`convertRadix2: carry overflow pos=${s} from=${r}`);for(s+=r;s>=n;s-=n)l.push((o>>s-n&a)>>>0);o&=2**s-1}if(o=o<<n-s&a,!i&&s>=r)throw new Error("Excess padding");if(!i&&o)throw new Error(`Non-zero padding: ${o}`);return i&&s>0&&l.push(o>>>0),l}function h(e){return t(e),{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix.encode input should be Uint8Array");return a(Array.from(t),256,e)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix.decode input should be array of strings");return Uint8Array.from(a(t,e,256))}}}function d(e,r=!1){if(t(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(c(8,e)>32||c(e,8)>32)throw new Error("radix2: carry overflow");return{encode:t=>{if(!(t instanceof Uint8Array))throw new Error("radix2.encode input should be Uint8Array");return u(Array.from(t),8,e,!r)},decode:t=>{if(!Array.isArray(t)||t.length&&"number"!=typeof t[0])throw new Error("radix2.decode input should be array of strings");return Uint8Array.from(u(t,e,8,r))}}}function p(e){if("function"!=typeof e)throw new Error("unsafeWrapper fn should be function");return function(...t){try{return e.apply(null,t)}catch(r){}}}function f(e,r){if(t(e),"function"!=typeof r)throw new Error("checksum fn should be function");return{encode(t){if(!(t instanceof Uint8Array))throw new Error("checksum.encode: input should be Uint8Array");const n=r(t).slice(0,e),i=new Uint8Array(t.length+e);return i.set(t),i.set(n,t.length),i},decode(t){if(!(t instanceof Uint8Array))throw new Error("checksum.decode: input should be Uint8Array");const n=t.slice(0,-e),i=r(n).slice(0,e),o=t.slice(-e);for(let r=0;r<e;r++)if(i[r]!==o[r])throw new Error("Invalid checksum");return n}}}e.utils={alphabet:n,chain:r,checksum:f,radix:h,radix2:d,join:i,padding:o},e.base16=r(d(4),n("0123456789ABCDEF"),i("")),e.base32=r(d(5),n("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),o(5),i("")),e.base32hex=r(d(5),n("0123456789ABCDEFGHIJKLMNOPQRSTUV"),o(5),i("")),e.base32crockford=r(d(5),n("0123456789ABCDEFGHJKMNPQRSTVWXYZ"),i(""),s(e=>e.toUpperCase().replace(/O/g,"0").replace(/[IL]/g,"1"))),e.base64=r(d(6),n("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),o(6),i("")),e.base64url=r(d(6),n("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"),o(6),i(""));const g=e=>r(h(58),n(e),i(""));e.base58=g("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),e.base58flickr=g("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"),e.base58xrp=g("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz");const m=[0,2,3,5,6,7,9,10,11];e.base58xmr={encode(t){let r="";for(let n=0;n<t.length;n+=8){const i=t.subarray(n,n+8);r+=e.base58.encode(i).padStart(m[i.length],"1")}return r},decode(t){let r=[];for(let n=0;n<t.length;n+=11){const i=t.slice(n,n+11),o=m.indexOf(i.length),s=e.base58.decode(i);for(let e=0;e<s.length-o;e++)if(0!==s[e])throw new Error("base58xmr: wrong padding");r=r.concat(Array.from(s.slice(s.length-o)))}return Uint8Array.from(r)}},e.base58check=t=>r(f(4,e=>t(t(e))),e.base58);const y=r(n("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),i("")),b=[996825010,642813549,513874426,1027748829,705979059];function v(e){const t=e>>25;let r=(33554431&e)<<5;for(let n=0;n<b.length;n++)1==(t>>n&1)&&(r^=b[n]);return r}function _(e,t,r=1){const n=e.length;let i=1;for(let o=0;o<n;o++){const t=e.charCodeAt(o);if(t<33||t>126)throw new Error(`Invalid prefix (${e})`);i=v(i)^t>>5}i=v(i);for(let o=0;o<n;o++)i=v(i)^31&e.charCodeAt(o);for(let o of t)i=v(i)^o;for(let o=0;o<6;o++)i=v(i);return i^=r,y.encode(u([i%2**30],30,5,!1))}function E(e){const t="bech32"===e?1:734539939,r=d(5),n=r.decode,i=r.encode,o=p(n);function s(e,r=90){if("string"!=typeof e)throw new Error("bech32.decode input should be string, not "+typeof e);if(e.length<8||!1!==r&&e.length>r)throw new TypeError(`Wrong string length: ${e.length} (${e}). Expected (8..${r})`);const n=e.toLowerCase();if(e!==n&&e!==e.toUpperCase())throw new Error("String must be lowercase or uppercase");const i=(e=n).lastIndexOf("1");if(0===i||-1===i)throw new Error('Letter "1" must be present between prefix and data only');const o=e.slice(0,i),s=e.slLine truncated
|
||||||
|
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function isBytes$2(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function anumber$2(e,t=""){if(!Number.isSafeInteger(e)||e<0)throw new Error(`${t&&`"${t}" `}expected integer >= 0, got ${e}`)}function abytes$2(e,t,r=""){const n=isBytes$2(e),i=e?.length,o=void 0!==t;if(!n||o&&i!==t)throw new Error((r&&`"${r}" `)+"expected Uint8Array"+(o?` of length ${t}`:"")+", got "+(n?`length=${i}`:"type="+typeof e));return e}function ahash(e){if("function"!=typeof e||"function"!=typeof e.create)throw new Error("Hash must wrapped by utils.createHasher");anumber$2(e.outputLen),anumber$2(e.blockLen)}function aexists(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function aoutput(e,t){abytes$2(e,void 0,"digestInto() output");const r=t.outputLen;if(e.length<r)throw new Error('"digestInto() output" expected to be of length >='+r)}function clean$1(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function createView(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function rotr(e,t){return e<<32-t|e>>>t}const hasHexBuiltin=(()=>"function"==typeof Uint8Array.from([]).toHex&&"function"==typeof Uint8Array.fromHex)(),hexes=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function bytesToHex(e){if(abytes$2(e),hasHexBuiltin)return e.toHex();let t="";for(let r=0;r<e.length;r++)t+=hexes[e[r]];return t}const asciis={_0:48,_9:57,A:65,F:70,a:97,f:102};function asciiToBase16(e){return e>=asciis._0&&e<=asciis._9?e-asciis._0:e>=asciis.A&&e<=asciis.F?e-(asciis.A-10):e>=asciis.a&&e<=asciis.f?e-(asciis.a-10):void 0}function hexToBytes(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);if(hasHexBuiltin)return Uint8Array.fromHex(e);const t=e.length,r=t/2;if(t%2)throw new Error("hex string expected, got unpadded hex of length "+t);const n=new Uint8Array(r);for(let i=0,o=0;i<r;i++,o+=2){const t=asciiToBase16(e.charCodeAt(o)),r=asciiToBase16(e.charCodeAt(o+1));if(void 0===t||void 0===r){const t=e[o]+e[o+1];throw new Error('hex string expected, got non-hex character "'+t+'" at index '+o)}n[i]=16*t+r}return n}function concatBytes(...e){let t=0;for(let n=0;n<e.length;n++){const r=e[n];abytes$2(r),t+=r.length}const r=new Uint8Array(t);for(let n=0,i=0;n<e.length;n++){const t=e[n];r.set(t,i),i+=t.length}return r}function createHasher(e,t={}){const r=(t,r)=>e(r).update(t).digest(),n=e(void 0);return r.outputLen=n.outputLen,r.blockLen=n.blockLen,r.create=t=>e(t),Object.assign(r,t),Object.freeze(r)}function randomBytes(e=32){const t="object"==typeof globalThis?globalThis.crypto:null;if("function"!=typeof t?.getRandomValues)throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}const oidNist=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function Chi(e,t,r){return e&t^~e&r}function Maj(e,t,r){return e&t^e&r^t&r}class HashMD{blockLen;outputLen;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(e,t,r,n){this.blockLen=e,this.outputLen=t,this.padOffset=r,this.isLE=n,this.buffer=new Uint8Array(e),this.view=createView(this.buffer)}update(e){aexists(this),abytes$2(e);const{view:t,buffer:r,blockLen:n}=this,i=e.length;for(let o=0;o<i;){const s=Math.min(n-this.pos,i-o);if(s===n){const t=createView(e);for(;n<=i-o;o+=n)this.process(t,o);continue}r.set(e.subarray(o,o+s),this.pos),this.pos+=s,o+=s,this.pos===n&&(this.process(t,0),this.pos=0)}return this.length+=e.length,this.roundClean(),this}digestInto(e){aexists(this),aoutput(e,this),this.finished=!0;const{buffer:t,view:r,blockLen:n,isLE:i}=this;let{pos:o}=this;t[o++]=128,clean$1(this.buffer.subarray(o)),this.padOffset>n-o&&(this.process(r,0),o=0);for(let u=o;u<n;u++)t[u]=0;r.setBigUint64(n-8,BigInt(8*this.length),i),this.process(r,0);const s=createView(e),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen must be aligned to 32bit");const l=a/4,c=this.get();if(l>c.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u<l;u++)s.setUint32(4*u,c[u],i)}digest(){const{buffer:e,outputLen:t}=this;this.digestInto(e);const r=e.slice(0,t);return this.destroy(),r}_cloneInto(e){e||=new this.constructor,e.set(...this.get());const{blockLen:t,buffer:r,length:n,finished:i,destroyed:o,pos:s}=this;return e.destroyed=o,e.finished=i,e.length=n,e.pos=s,n%t&&e.buffer.set(r),e}clone(){return this._cloneInto()}}const SHA256_IV=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),SHA256_K=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,358452871Line truncated
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function abool$1(e,t=""){if("boolean"!=typeof e)throw new Error((t&&`"${t}" `)+"expected boolean, got type="+typeof e);return e}function abignumber(e){if("bigint"==typeof e){if(!isPosBig(e))throw new Error("positive bigint expected, got "+e)}else anumber$2(e);return e}function numberToHexUnpadded(e){const t=abignumber(e).toString(16);return 1&t.length?"0"+t:t}function hexToNumber(e){if("string"!=typeof e)throw new Error("hex string expected, got "+typeof e);return""===e?_0n$4:BigInt("0x"+e)}function bytesToNumberBE(e){return hexToNumber(bytesToHex(e))}function bytesToNumberLE(e){return hexToNumber(bytesToHex(copyBytes$1(abytes$2(e)).reverse()))}function numberToBytesBE(e,t){anumber$2(t);const r=hexToBytes((e=abignumber(e)).toString(16).padStart(2*t,"0"));if(r.length!==t)throw new Error("number too large");return r}function numberToBytesLE(e,t){return numberToBytesBE(e,t).reverse()}function copyBytes$1(e){return Uint8Array.from(e)}function asciiToBytes(e){return Uint8Array.from(e,(t,r)=>{const n=t.charCodeAt(0);if(1!==t.length||n>127)throw new Error(`string contains non-ASCII character "${e[r]}" with code ${n} at position ${r}`);return n})}const isPosBig=e=>"bigint"==typeof e&&_0n$4<=e;function inRange(e,t,r){return isPosBig(e)&&isPosBig(t)&&isPosBig(r)&&t<=e&&e<r}function aInRange(e,t,r,n){if(!inRange(t,r,n))throw new Error("expected valid "+e+": "+r+" <= n < "+n+", got "+t)}function bitLen(e){let t;for(t=0;e>_0n$4;e>>=_1n$3,t+=1);return t}const bitMask=e=>(_1n$3<<BigInt(e))-_1n$3;function createHmacDrbg(e,t,r){if(anumber$2(e,"hashLen"),anumber$2(t,"qByteLen"),"function"!=typeof r)throw new Error("hmacFn must be a function");const n=e=>new Uint8Array(e),i=Uint8Array.of(),o=Uint8Array.of(0),s=Uint8Array.of(1);let a=n(e),l=n(e),c=0;const u=()=>{a.fill(1),l.fill(0),c=0},h=(...e)=>r(l,concatBytes(a,...e)),d=(e=i)=>{l=h(o,e),a=h(),0!==e.length&&(l=h(s,e),a=h())},p=()=>{if(c++>=1e3)throw new Error("drbg: tried max amount of iterations");let e=0;const r=[];for(;e<t;){a=h();const t=a.slice();r.push(t),e+=a.length}return concatBytes(...r)};return(e,t)=>{let r;for(u(),d(e);!(r=t(p()));)d();return u(),r}}function validateObject(e,t={},r={}){if(!e||"object"!=typeof e)throw new Error("expected valid options object");const n=(t,r)=>Object.entries(t).forEach(([t,n])=>function(t,r,n){const i=e[t];if(n&&void 0===i)return;const o=typeof i;if(o!==r||null===i)throw new Error(`param "${t}" is invalid: expected ${r}, got ${o}`)}(t,n,r));n(t,!1),n(r,!0)}function memoized(e){const t=new WeakMap;return(r,...n)=>{const i=t.get(r);if(void 0!==i)return i;const o=e(r,...n);return t.set(r,o),o}}
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$3=BigInt(0),_1n$2=BigInt(1),_2n$2=BigInt(2),_3n$1=BigInt(3),_4n$1=BigInt(4),_5n=BigInt(5),_7n=BigInt(7),_8n=BigInt(8),_9n=BigInt(9),_16n=BigInt(16);function mod(e,t){const r=e%t;return r>=_0n$3?r:t+r}function pow2(e,t,r){let n=e;for(;t-- >_0n$3;)n*=n,n%=r;return n}function invert(e,t){if(e===_0n$3)throw new Error("invert: expected non-zero number");if(t<=_0n$3)throw new Error("invert: expected positive modulus, got "+t);let r=mod(e,t),n=t,i=_0n$3,o=_1n$2;for(;r!==_0n$3;){const e=n%r,t=i-o*(n/r);n=r,r=e,i=o,o=t}if(n!==_1n$2)throw new Error("invert: does not exist");return mod(i,t)}function assertIsSquare(e,t,r){if(!e.eql(e.sqr(t),r))throw new Error("Cannot find square root")}function sqrt3mod4(e,t){const r=(e.ORDER+_1n$2)/_4n$1,n=e.pow(t,r);return assertIsSquare(e,n,t),n}function sqrt5mod8(e,t){const r=(e.ORDER-_5n)/_8n,n=e.mul(t,_2n$2),i=e.pow(n,r),o=e.mul(t,i),s=e.mul(e.mul(o,_2n$2),i),a=e.mul(o,e.sub(s,e.ONE));return assertIsSquare(e,a,t),a}function sqrt9mod16(e){const t=Field(e),r=tonelliShanks(e),n=r(t,t.neg(t.ONE)),i=r(t,n),o=r(t,t.neg(n)),s=(e+_7n)/_16n;return(e,t)=>{let r=e.pow(t,s),a=e.mul(r,n);const l=e.mul(r,i),c=e.mul(r,o),u=e.eql(e.sqr(a),t),h=e.eql(e.sqr(l),t);r=e.cmov(r,a,u),a=e.cmov(c,l,h);const d=e.eql(e.sqr(a),t),p=e.cmov(r,a,d);return assertIsSquare(e,p,t),p}}function tonelliShanks(e){if(e<_3n$1)throw new Error("sqrt is not defined for small field");let t=e-_1n$2,r=0;for(;t%_2n$2===_0n$3;)t/=_2n$2,r++;let n=_2n$2;const i=Field(e);for(;1===FpLegendre(i,n);)if(n++>1e3)throw new Error("Cannot find square root: probably non-prime P");if(1===r)return sqrt3mod4;let o=i.pow(n,t);const s=(t+_1n$2)/_2n$2;return function(e,n){if(e.is0(n))return n;if(1!==FpLegendre(e,n))throw new Error("Cannot find square root");let i=r,a=e.mul(e.ONE,o),l=e.pow(n,t),c=e.pow(n,s);for(;!e.eql(l,e.ONE);){if(e.is0(l))return e.ZERO;let t=1,r=e.sqr(l);for(;!e.eql(r,e.ONE);)if(t++,r=e.sqr(r),t===i)throw new Error("Cannot find square root");const n=_1n$2<<BigInt(i-t-1),o=e.pow(a,n);i=t,a=e.sqr(o),l=e.mul(l,a),c=e.mul(c,o)}return c}}function FpSqrt(e){return e%_4n$1===_3n$1?sqrt3mod4:e%_8n===_5n?sqrt5mod8:e%_16n===_9n?sqrt9mod16(e):tonelliShanks(e)}const FIELD_FIELDS=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function validateField(e){const t=FIELD_FIELDS.reduce((e,t)=>(e[t]="function",e),{ORDER:"bigint",BYTES:"number",BITS:"number"});return validateObject(e,t),e}function FpPow(e,t,r){if(r<_0n$3)throw new Error("invalid exponent, negatives unsupported");if(r===_0n$3)return e.ONE;if(r===_1n$2)return t;let n=e.ONE,i=t;for(;r>_0n$3;)r&_1n$2&&(n=e.mul(n,i)),i=e.sqr(i),r>>=_1n$2;return n}function FpInvertBatch(e,t,r=!1){const n=new Array(t.length).fill(r?e.ZERO:void 0),i=t.reduce((t,r,i)=>e.is0(r)?t:(n[i]=t,e.mul(t,r)),e.ONE),o=e.inv(i);return t.reduceRight((t,r,i)=>e.is0(r)?t:(n[i]=e.mul(t,n[i]),e.mul(t,r)),o),n}function FpLegendre(e,t){const r=(e.ORDER-_1n$2)/_2n$2,n=e.pow(t,r),i=e.eql(n,e.ONE),o=e.eql(n,e.ZERO),s=e.eql(n,e.neg(e.ONE));if(!i&&!o&&!s)throw new Error("invalid Legendre symbol result");return i?1:o?0:-1}function nLength(e,t){void 0!==t&&anumber$2(t);const r=void 0!==t?t:e.toString(2).length;return{nBitLength:r,nByteLength:Math.ceil(r/8)}}class _Field{ORDER;BITS;BYTES;isLE;ZERO=_0n$3;ONE=_1n$2;_lengths;_sqrt;_mod;constructor(e,t={}){if(e<=_0n$3)throw new Error("invalid field: expected ORDER > 0, got "+e);let r;this.isLE=!1,null!=t&&"object"==typeof t&&("number"==typeof t.BITS&&(r=t.BITS),"function"==typeof t.sqrt&&(this.sqrt=t.sqrt),"boolean"==typeof t.isLE&&(this.isLE=t.isLE),t.allowedLengths&&(this._lengths=t.allowedLengths?.slice()),"boolean"==typeof t.modFromBytes&&(this._mod=t.modFromBytes));const{nBitLength:n,nByteLength:i}=nLength(e,r);if(i>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");this.ORDER=e,this.BITS=n,this.BYTES=i,this._sqrt=void 0,Object.preventExtensions(this)}create(e){return mod(e,this.ORDER)}isValid(e){if("bigint"!=typeof e)throw new Error("invalid field element: expected bigint, got "+typeof e);return _0n$3<=e&&e<this.ORDER}is0(e){return e===_0n$3}isValidNot0(e){return!this.is0(e)&&this.isValid(e)}isOdd(e){return(e&_1n$2)===_1n$2}neg(e){return mod(-e,this.ORDER)}eql(e,t){return e===t}sqr(e){return mod(e*e,this.ORDER)}add(e,t){return mod(e+t,this.ORDER)}sub(e,t){return mod(e-t,this.ORDER)}mul(e,t){return mod(e*t,this.ORDER)}pow(e,t){return FpPow(this,e,t)}div(e,t){return mod(e*invert(t,this.ORDER),this.ORDER)}sqrN(e){return e*e}addN(e,t){return e+t}subN(e,t){return e-t}mulN(e,t){return e*t}inv(e){return invert(e,this.ORDER)}sqrt(e){return this._sqrt||(this._sqrt=FpSqrt(this.ORDER)),this._sqrt(this,e)}toBytes(e){return this.isLE?numberToBytesLE(e,this.BYTES):numberToBytesBE(e,this.BYTES)}fromBytes(e,t=!1){abytes$2(e);const{_lengths:r,BYTES:n,isLE:i,ORDER:o,_mod:s}=this;if(r){if(!r.includes(e.length)||e.length>n)throw new
Line truncated
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$2=BigInt(0),_1n$1=BigInt(1);function negateCt(e,t){const r=t.negate();return e?r:t}function normalizeZ(e,t){const r=FpInvertBatch(e.Fp,t.map(e=>e.Z));return t.map((t,n)=>e.fromAffine(t.toAffine(r[n])))}function validateW(e,t){if(!Number.isSafeInteger(e)||e<=0||e>t)throw new Error("invalid window size, expected [1.."+t+"], got W="+e)}function calcWOpts(e,t){validateW(e,t);const r=2**e;return{windows:Math.ceil(t/e)+1,windowSize:2**(e-1),mask:bitMask(e),maxNumber:r,shiftBy:BigInt(e)}}function calcOffsets(e,t,r){const{windowSize:n,mask:i,maxNumber:o,shiftBy:s}=r;let a=Number(e&i),l=e>>s;a>n&&(a-=o,l+=_1n$1);const c=t*n;return{nextN:l,offset:c+Math.abs(a)-1,isZero:0===a,isNeg:a<0,isNegF:t%2!=0,offsetF:c}}const pointPrecomputes=new WeakMap,pointWindowSizes=new WeakMap;function getW(e){return pointWindowSizes.get(e)||1}function assert0(e){if(e!==_0n$2)throw new Error("invalid wNAF")}class wNAF{BASE;ZERO;Fn;bits;constructor(e,t){this.BASE=e.BASE,this.ZERO=e.ZERO,this.Fn=e.Fn,this.bits=t}_unsafeLadder(e,t,r=this.ZERO){let n=e;for(;t>_0n$2;)t&_1n$1&&(r=r.add(n)),n=n.double(),t>>=_1n$1;return r}precomputeWindow(e,t){const{windows:r,windowSize:n}=calcWOpts(t,this.bits),i=[];let o=e,s=o;for(let a=0;a<r;a++){s=o,i.push(s);for(let e=1;e<n;e++)s=s.add(o),i.push(s);o=s.double()}return i}wNAF(e,t,r){if(!this.Fn.isValid(r))throw new Error("invalid scalar");let n=this.ZERO,i=this.BASE;const o=calcWOpts(e,this.bits);for(let s=0;s<o.windows;s++){const{nextN:e,offset:a,isZero:l,isNeg:c,isNegF:u,offsetF:h}=calcOffsets(r,s,o);r=e,l?i=i.add(negateCt(u,t[h])):n=n.add(negateCt(c,t[a]))}return assert0(r),{p:n,f:i}}wNAFUnsafe(e,t,r,n=this.ZERO){const i=calcWOpts(e,this.bits);for(let o=0;o<i.windows&&r!==_0n$2;o++){const{nextN:e,offset:s,isZero:a,isNeg:l}=calcOffsets(r,o,i);if(r=e,!a){const e=t[s];n=n.add(l?e.negate():e)}}return assert0(r),n}getPrecomputes(e,t,r){let n=pointPrecomputes.get(t);return n||(n=this.precomputeWindow(t,e),1!==e&&("function"==typeof r&&(n=r(n)),pointPrecomputes.set(t,n))),n}cached(e,t,r){const n=getW(e);return this.wNAF(n,this.getPrecomputes(n,e,r),t)}unsafe(e,t,r,n){const i=getW(e);return 1===i?this._unsafeLadder(e,t,n):this.wNAFUnsafe(i,this.getPrecomputes(i,e,r),t,n)}createCache(e,t){validateW(t,this.bits),pointWindowSizes.set(e,t),pointPrecomputes.delete(e)}hasCache(e){return 1!==getW(e)}}function mulEndoUnsafe(e,t,r,n){let i=t,o=e.ZERO,s=e.ZERO;for(;r>_0n$2||n>_0n$2;)r&_1n$1&&(o=o.add(i)),n&_1n$1&&(s=s.add(i)),i=i.double(),r>>=_1n$1,n>>=_1n$1;return{p1:o,p2:s}}function createField(e,t,r){if(t){if(t.ORDER!==e)throw new Error("Field.ORDER must match order: Fp == p, Fn == n");return validateField(t),t}return Field(e,{isLE:r})}function createCurveFields(e,t,r={},n){if(void 0===n&&(n="edwards"===e),!t||"object"!=typeof t)throw new Error(`expected valid ${e} CURVE object`);for(const a of["p","n","h"]){const e=t[a];if(!("bigint"==typeof e&&e>_0n$2))throw new Error(`CURVE.${a} must be positive bigint`)}const i=createField(t.p,r.Fp,n),o=createField(t.n,r.Fn,n),s=["Gx","Gy","a","b"];for(const a of s)if(!i.isValid(t[a]))throw new Error(`CURVE.${a} must be valid field element of CURVE.Fp`);return{CURVE:t=Object.freeze(Object.assign({},t)),Fp:i,Fn:o}}function createKeygen(e,t){return function(r){const n=e(r);return{secretKey:n,publicKey:t(n)}}}class _HMAC{oHash;iHash;blockLen;outputLen;finished=!1;destroyed=!1;constructor(e,t){if(ahash(e),abytes$2(t,void 0,"key"),this.iHash=e.create(),"function"!=typeof this.iHash.update)throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;const r=this.blockLen,n=new Uint8Array(r);n.set(t.length>r?e.create().update(t).digest():t);for(let i=0;i<n.length;i++)n[i]^=54;this.iHash.update(n),this.oHash=e.create();for(let i=0;i<n.length;i++)n[i]^=106;this.oHash.update(n),clean$1(n)}update(e){return aexists(this),this.iHash.update(e),this}digestInto(e){aexists(this),abytes$2(e,this.outputLen,"output"),this.finished=!0,this.iHash.digestInto(e),this.oHash.update(e),this.oHash.digestInto(e),this.destroy()}digest(){const e=new Uint8Array(this.oHash.outputLen);return this.digestInto(e),e}_cloneInto(e){e||=Object.create(Object.getPrototypeOf(this),{});const{oHash:t,iHash:r,finished:n,destroyed:i,blockLen:o,outputLen:s}=this;return e.finished=n,e.destroyed=i,e.blockLen=o,e.outputLen=s,e.oHash=t._cloneInto(e.oHash),e.iHash=r._cloneInto(e.iHash),e}clone(){return this._cloneInto()}destroy(){this.destroyed=!0,this.oHash.destroy(),this.iHash.destroy()}}const hmac=(e,t,r)=>new _HMAC(e,t).update(r).digest();hmac.create=(e,t)=>new _HMAC(e,t);
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
|
||||||
|
const divNearest=(e,t)=>(e+(e>=0?t:-t)/_2n$1)/t;function _splitEndoScalar(e,t,r){const[[n,i],[o,s]]=t,a=divNearest(s*e,r),l=divNearest(-i*e,r);let c=e-a*n-l*o,u=-a*i-l*s;const h=c<_0n$1,d=u<_0n$1;h&&(c=-c),d&&(u=-u);const p=bitMask(Math.ceil(bitLen(r)/2))+_1n;if(c<_0n$1||c>=p||u<_0n$1||u>=p)throw new Error("splitScalar (endomorphism): failed, k="+e);return{k1neg:h,k1:c,k2neg:d,k2:u}}function validateSigFormat(e){if(!["compact","recovered","der"].includes(e))throw new Error('Signature format must be "compact", "recovered", or "der"');return e}function validateSigOpts(e,t){const r={};for(let n of Object.keys(t))r[n]=void 0===e[n]?t[n]:e[n];return abool$1(r.lowS,"lowS"),abool$1(r.prehash,"prehash"),void 0!==r.format&&validateSigFormat(r.format),r}class DERErr extends Error{constructor(e=""){super(e)}}const DER={Err:DERErr,_tlv:{encode:(e,t)=>{const{Err:r}=DER;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(1&t.length)throw new r("tlv.encode: unpadded data");const n=t.length/2,i=numberToHexUnpadded(n);if(i.length/2&128)throw new r("tlv.encode: long form length too big");const o=n>127?numberToHexUnpadded(i.length/2|128):"";return numberToHexUnpadded(e)+o+i+t},decode(e,t){const{Err:r}=DER;let n=0;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(t.length<2||t[n++]!==e)throw new r("tlv.decode: wrong tlv");const i=t[n++];let o=0;if(128&i){const e=127&i;if(!e)throw new r("tlv.decode(long): indefinite length not supported");if(e>4)throw new r("tlv.decode(long): byte length is too big");const s=t.subarray(n,n+e);if(s.length!==e)throw new r("tlv.decode: length bytes not complete");if(0===s[0])throw new r("tlv.decode(long): zero leftmost byte");for(const t of s)o=o<<8|t;if(n+=e,o<128)throw new r("tlv.decode(long): not minimal encoding")}else o=i;const s=t.subarray(n,n+o);if(s.length!==o)throw new r("tlv.decode: wrong value length");return{v:s,l:t.subarray(n+o)}}},_int:{encode(e){const{Err:t}=DER;if(e<_0n$1)throw new t("integer: negative integers are not allowed");let r=numberToHexUnpadded(e);if(8&Number.parseInt(r[0],16)&&(r="00"+r),1&r.length)throw new t("unexpected DER parsing assertion: unpadded hex");return r},decode(e){const{Err:t}=DER;if(128&e[0])throw new t("invalid signature integer: negative");if(0===e[0]&&!(128&e[1]))throw new t("invalid signature integer: unnecessary leading zero");return bytesToNumberBE(e)}},toSig(e){const{Err:t,_int:r,_tlv:n}=DER,i=abytes$2(e,void 0,"signature"),{v:o,l:s}=n.decode(48,i);if(s.length)throw new t("invalid signature: left bytes after parsing");const{v:a,l:l}=n.decode(2,o),{v:c,l:u}=n.decode(2,l);if(u.length)throw new t("invalid signature: left bytes after parsing");return{r:r.decode(a),s:r.decode(c)}},hexFromSig(e){const{_tlv:t,_int:r}=DER,n=t.encode(2,r.encode(e.r))+t.encode(2,r.encode(e.s));return t.encode(48,n)}},_0n$1=BigInt(0),_1n=BigInt(1),_2n$1=BigInt(2),_3n=BigInt(3),_4n=BigInt(4);function weierstrass(e,t={}){const r=createCurveFields("weierstrass",e,t),{Fp:n,Fn:i}=r;let o=r.CURVE;const{h:s,n:a}=o;validateObject(t,{},{allowInfinityPoint:"boolean",clearCofactor:"function",isTorsionFree:"function",fromBytes:"function",toBytes:"function",endo:"object"});const{endo:l}=t;if(l&&(!n.is0(o.a)||"bigint"!=typeof l.beta||!Array.isArray(l.basises)))throw new Error('invalid endo: expected "beta": bigint and "basises": array');const c=getWLengths(n,i);function u(){if(!n.isOdd)throw new Error("compression is not supported: Field does not have .isOdd()")}const h=t.toBytes||function(e,t,r){const{x:i,y:o}=t.toAffine(),s=n.toBytes(i);return abool$1(r,"isCompressed"),r?(u(),concatBytes(pprefix(!n.isOdd(o)),s)):concatBytes(Uint8Array.of(4),s,n.toBytes(o))},d=t.fromBytes||function(e){abytes$2(e,void 0,"Point");const{publicKey:t,publicKeyUncompressed:r}=c,i=e.length,o=e[0],s=e.subarray(1);if(i!==t||2!==o&&3!==o){if(i===r&&4===o){const e=n.BYTES,t=n.fromBytes(s.subarray(0,e)),r=n.fromBytes(s.subarray(e,2*e));if(!f(t,r))throw new Error("bad point: is not on curve");return{x:t,y:r}}throw new Error(`bad point: got length ${i}, expected compressed=${t} or uncompressed=${r}`)}{const e=n.fromBytes(s);if(!n.isValid(e))throw new Error("bad point: is not on curve, wrong x");const t=p(e);let r;try{r=n.sqrt(t)}catch(a){const e=a instanceof Error?": "+a.message:"";throw new Error("bad point: is not on curve, sqrt error"+e)}return u(),!(1&~o)!==n.isOdd(r)&&(r=n.neg(r)),{x:e,y:r}}};function p(e){const t=n.sqr(e),r=n.mul(t,e);return n.add(n.add(r,n.mul(e,o.a)),o.b)}function f(e,t){const r=n.sqr(t),i=p(e);return n.eql(r,i)}if(!f(o.Gx,o.Gy))throw new Error("bad curve params: generator point");const g=n.mul(n.pow(o.a,_3n),_4n),m=n.mul(n.sqr(o.b),BigInt(27));if(n.is0(n.add(g,m)))throw new Error("bad curve params: a or b");function y(e,t,r=!1){if(!n.isValid(t)||r&&n.is0(t))throw new Error(`bad point coordinate ${e}`);return t}function b(e){if(!(e instanceof C))throw new Error("Weierstrass Point expected")}function v(e){if(!l||!l.basises)throw new Error("no endo");return _splitEndoScalar(e,l.basises,i.OLine truncated
|
||||||
|
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const secp256k1_CURVE={p:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:BigInt(1),a:BigInt(0),b:BigInt(7),Gx:BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),Gy:BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")},secp256k1_ENDO={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),basises:[[BigInt("0x3086d221a7d46bcde86c90e49284eb15"),-BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],[BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),BigInt("0x3086d221a7d46bcde86c90e49284eb15")]]},_0n=BigInt(0),_2n=BigInt(2);function sqrtMod(e){const t=secp256k1_CURVE.p,r=BigInt(3),n=BigInt(6),i=BigInt(11),o=BigInt(22),s=BigInt(23),a=BigInt(44),l=BigInt(88),c=e*e*e%t,u=c*c*e%t,h=pow2(u,r,t)*u%t,d=pow2(h,r,t)*u%t,p=pow2(d,_2n,t)*c%t,f=pow2(p,i,t)*p%t,g=pow2(f,o,t)*f%t,m=pow2(g,a,t)*g%t,y=pow2(m,l,t)*m%t,b=pow2(y,a,t)*g%t,v=pow2(b,r,t)*u%t,_=pow2(v,s,t)*f%t,E=pow2(_,n,t)*c%t,w=pow2(E,_2n,t);if(!Fpk1.eql(Fpk1.sqr(w),e))throw new Error("Cannot find square root");return w}const Fpk1=Field(secp256k1_CURVE.p,{sqrt:sqrtMod}),Pointk1=weierstrass(secp256k1_CURVE,{Fp:Fpk1,endo:secp256k1_ENDO}),secp256k1=ecdsa(Pointk1,sha256),TAGGED_HASH_PREFIXES={};function taggedHash(e,...t){let r=TAGGED_HASH_PREFIXES[e];if(void 0===r){const t=sha256(asciiToBytes(e));r=concatBytes(t,t),TAGGED_HASH_PREFIXES[e]=r}return sha256(concatBytes(r,...t))}const pointToBytes=e=>e.toBytes(!0).slice(1),hasEven=e=>e%_2n===_0n;function schnorrGetExtPubKey(e){const{Fn:t,BASE:r}=Pointk1,n=t.fromBytes(e),i=r.multiply(n);return{scalar:hasEven(i.y)?n:t.neg(n),bytes:pointToBytes(i)}}function lift_x(e){const t=Fpk1;if(!t.isValidNot0(e))throw new Error("invalid x: Fail if x ≥ p");const r=t.create(e*e),n=t.create(r*e+BigInt(7));let i=t.sqrt(n);hasEven(i)||(i=t.neg(i));const o=Pointk1.fromAffine({x:e,y:i});return o.assertValidity(),o}const num=bytesToNumberBE;function challenge(...e){return Pointk1.Fn.create(num(taggedHash("BIP0340/challenge",...e)))}function schnorrGetPublicKey(e){return schnorrGetExtPubKey(e).bytes}function schnorrSign(e,t,r=randomBytes(32)){const{Fn:n}=Pointk1,i=abytes$2(e,void 0,"message"),{bytes:o,scalar:s}=schnorrGetExtPubKey(t),a=abytes$2(r,32,"auxRand"),l=n.toBytes(s^num(taggedHash("BIP0340/aux",a))),c=taggedHash("BIP0340/nonce",l,o,i),{bytes:u,scalar:h}=schnorrGetExtPubKey(c),d=challenge(u,o,i),p=new Uint8Array(64);if(p.set(u,0),p.set(n.toBytes(n.create(h+d*s)),32),!schnorrVerify(p,i,o))throw new Error("sign: Invalid signature produced");return p}function schnorrVerify(e,t,r){const{Fp:n,Fn:i,BASE:o}=Pointk1,s=abytes$2(e,64,"signature"),a=abytes$2(t,void 0,"message"),l=abytes$2(r,32,"publicKey");try{const e=lift_x(num(l)),t=num(s.subarray(0,32));if(!n.isValidNot0(t))return!1;const r=num(s.subarray(32,64));if(!i.isValidNot0(r))return!1;const c=challenge(i.toBytes(t),pointToBytes(e),a),u=o.multiplyUnsafe(r).add(e.multiplyUnsafe(i.neg(c))),{x:h,y:d}=u.toAffine();return!(u.is0()||!hasEven(d)||h!==t)}catch(error){return!1}}const schnorr=(()=>{const e=(e=randomBytes(48))=>mapHashToField(e,secp256k1_CURVE.n);return{keygen:createKeygen(e,schnorrGetPublicKey),getPublicKey:schnorrGetPublicKey,sign:schnorrSign,verify:schnorrVerify,Point:Pointk1,utils:{randomSecretKey:e,taggedHash:taggedHash,lift_x:lift_x,pointToBytes:pointToBytes},lengths:{secretKey:32,publicKey:32,publicKeyHasPrefix:!1,signature:64,seed:48}}})();
|
||||||
|
/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */function isBytes$1(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function abytes$1(e){if(!isBytes$1(e))throw new Error("Uint8Array expected")}function isArrayOf(e,t){return!!Array.isArray(t)&&(0===t.length||(e?t.every(e=>"string"==typeof e):t.every(e=>Number.isSafeInteger(e))))}function afn(e){if("function"!=typeof e)throw new Error("function expected");return!0}function astr(e,t){if("string"!=typeof t)throw new Error(`${e}: string expected`);return!0}function anumber$1(e){if(!Number.isSafeInteger(e))throw new Error(`invalid integer: ${e}`)}function aArr(e){if(!Array.isArray(e))throw new Error("array expected")}function astrArr(e,t){if(!isArrayOf(!0,t))throw new Error(`${e}: array of strings expected`)}function anumArr(e,t){if(!isArrayOf(!1,t))throw new Error(`${e}: array of numbers expected`)}function chain(...e){const t=e=>e,r=(e,t)=>r=>e(t(r)),n=e.map(e=>e.encode).reduceRight(r,t),i=e.map(e=>e.decode).reduce(r,t);return{encode:n,decode:i}}function alphabet(e){const t="string"==typeof e?e.split(""):e,r=t.length;astrArr("alphabet",t);const n=new Map(t.map((e,t)=>[e,t]));return{encode:n=>(aArr(n),n.map(n=>{if(!Number.isSafeInteger(n)||n<0||n>=r)throw new Error(`alphabet.encode: digit index outside alphabet "${n}". Allowed: ${e}`);return t[n]})),decode:t=>(aArr(t),t.map(t=>{astr("alphabet.decode",t);const r=n.get(t);if(void 0===r)throw new Error(`Unknown letter: "${t}". Allowed: ${e}`);return r}))}}function join(e=""){return astr("join",e),{encode:t=>(astrArr("join.decode",t),t.join(e)),decode:t=>(astr("join.decode",t),t.split(e))}}function padding(e,t="="){return anumber$1(e),astr("padding",t),{encode(r){for(astrArr("padding.encode",r);r.length*e%8;)r.push(t);return r},decode(r){astrArr("padding.decode",r);let n=r.length;if(n*e%8)throw new Error("padding: invalid, string should have whole number of bytes");for(;n>0&&r[n-1]===t;n--)if((n-1)*e%8==0)throw new Error("padding: invalid, string has too much padding");return r.slice(0,n)}}}const gcd=(e,t)=>0===t?e:gcd(t,e%t),radix2carry=(e,t)=>e+(t-gcd(e,t)),powers=(()=>{let e=[];for(let t=0;t<40;t++)e.push(2**t);return e})();function convertRadix2(e,t,r,n){if(aArr(e),t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(r<=0||r>32)throw new Error(`convertRadix2: wrong to=${r}`);if(radix2carry(t,r)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${r} carryBits=${radix2carry(t,r)}`);let i=0,o=0;const s=powers[t],a=powers[r]-1,l=[];for(const c of e){if(anumber$1(c),c>=s)throw new Error(`convertRadix2: invalid data word=${c} from=${t}`);if(i=i<<t|c,o+t>32)throw new Error(`convertRadix2: carry overflow pos=${o} from=${t}`);for(o+=t;o>=r;o-=r)l.push((i>>o-r&a)>>>0);const e=powers[o];if(void 0===e)throw new Error("invalid carry");i&=e-1}if(i=i<<r-o&a,!n&&o>=t)throw new Error("Excess padding");if(!n&&i>0)throw new Error(`Non-zero padding: ${i}`);return n&&o>0&&l.push(i>>>0),l}function radix2(e,t=!1){if(anumber$1(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(radix2carry(8,e)>32||radix2carry(e,8)>32)throw new Error("radix2: carry overflow");return{encode:r=>{if(!isBytes$1(r))throw new Error("radix2.encode input should be Uint8Array");return convertRadix2(Array.from(r),8,e,!t)},decode:r=>(anumArr("radix2.decode",r),Uint8Array.from(convertRadix2(r,e,8,t)))}}function unsafeWrapper(e){return afn(e),function(...t){try{return e.apply(null,t)}catch(r){}}}const hasBase64Builtin=(()=>"function"==typeof Uint8Array.from([]).toBase64&&"function"==typeof Uint8Array.fromBase64)(),decodeBase64Builtin=e=>{if(astr("base64",e),e.length>0&&!/^[A-Za-z0-9=+/]+$/.test(e))throw new Error("invalid base64");return Uint8Array.fromBase64(e,{alphabet:"base64",lastChunkHandling:"strict"})},base64=hasBase64Builtin?{encode:e=>(abytes$1(e),e.toBase64()),decode:e=>decodeBase64Builtin(e)}:chain(radix2(6),alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),padding(6),join("")),BECH_ALPHABET=chain(alphabet("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),join("")),POLYMOD_GENERATORS=[996825010,642813549,513874426,1027748829,705979059];function bech32Polymod(e){const t=e>>25;let r=(33554431&e)<<5;for(let n=0;n<POLYMOD_GENERATORS.length;n++)1==(t>>n&1)&&(r^=POLYMOD_GENERATORS[n]);return r}function bechChecksum(e,t,r=1){const n=e.length;let i=1;for(let o=0;o<n;o++){const t=e.charCodeAt(o);if(t<33||t>126)throw new Error(`Invalid prefix (${e})`);i=bech32Polymod(i)^t>>5}i=bech32Polymod(i);for(let o=0;o<n;o++)i=bech32Polymod(i)^31&e.charCodeAt(o);for(let o of t)i=bech32Polymod(i)^o;for(let o=0;o<6;o++)i=bech32Polymod(i);return i^=r,BECH_ALPHABET.encode(convertRadix2([i%powers[30]],30,5,!1))}function genBech32(e){const t="bech32"===e?1:734539939,r=radix2(5),n=r.decode,i=r.encode,o=unsafeWrapper(n);function s(e,r,n=90){astr("bech32.encode prefix",e),isBytes$1(r)&&(r=Array.from(r)),anumArr("bech32.encode",r);const i=e.length;if(0===i)throw new TyLine truncated
|
||||||
|
/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */function isBytes(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&"Uint8Array"===e.constructor.name}function abool(e){if("boolean"!=typeof e)throw new Error(`boolean expected, not ${e}`)}function anumber(e){if(!Number.isSafeInteger(e)||e<0)throw new Error("positive integer expected, got "+e)}function abytes(e,t,r=""){const n=isBytes(e),i=e?.length,o=void 0!==t;if(!n||o&&i!==t)throw new Error((r&&`"${r}" `)+"expected Uint8Array"+(o?` of length ${t}`:"")+", got "+(n?`length=${i}`:"type="+typeof e));return e}function u32(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function clean(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}const isLE=(()=>68===new Uint8Array(new Uint32Array([287454020]).buffer)[0])();function overlapBytes(e,t){return e.buffer===t.buffer&&e.byteOffset<t.byteOffset+t.byteLength&&t.byteOffset<e.byteOffset+e.byteLength}function complexOverlapBytes(e,t){if(overlapBytes(e,t)&&e.byteOffset<t.byteOffset)throw new Error("complex overlap of input and output is not supported")}function checkOpts(e,t){if(null==t||"object"!=typeof t)throw new Error("options must be defined");return Object.assign(e,t)}function equalBytes(e,t){if(e.length!==t.length)return!1;let r=0;for(let n=0;n<e.length;n++)r|=e[n]^t[n];return 0===r}const wrapCipher=(e,t)=>{function r(r,...n){if(abytes(r,void 0,"key"),!isLE)throw new Error("Non little-endian hardware is not yet supported");void 0!==e.nonceLength&&abytes(n[0],e.varSizeNonce?void 0:e.nonceLength,"nonce");const i=e.tagLength;i&&void 0!==n[1]&&abytes(n[1],void 0,"AAD");const o=t(r,...n),s=(e,t)=>{if(void 0!==t){if(2!==e)throw new Error("cipher output not supported");abytes(t,void 0,"output")}};let a=!1;const l={encrypt(e,t){if(a)throw new Error("cannot encrypt() twice with same key + nonce");return a=!0,abytes(e),s(o.encrypt.length,t),o.encrypt(e,t)},decrypt(e,t){if(abytes(e),i&&e.length<i)throw new Error('"ciphertext" expected length bigger than tagLength='+i);return s(o.decrypt.length,t),o.decrypt(e,t)}};return l}return Object.assign(r,e),r};function getOutput(e,t,r=!0){if(void 0===t)return new Uint8Array(e);if(t.length!==e)throw new Error('"output" expected Uint8Array of length '+e+", got: "+t.length);if(r&&!isAligned32$1(t))throw new Error("invalid output, must be aligned");return t}function isAligned32$1(e){return e.byteOffset%4==0}function copyBytes(e){return Uint8Array.from(e)}const BLOCK_SIZE=16,POLY=283;function validateKeyLength(e){if(![16,24,32].includes(e.length))throw new Error('"aes key" expected Uint8Array of length 16/24/32, got length='+e.length)}function mul2(e){return e<<1^POLY&-(e>>7)}function mul(e,t){let r=0;for(;t>0;t>>=1)r^=e&-(1&t),e=mul2(e);return r}const sbox=(()=>{const e=new Uint8Array(256);for(let r=0,n=1;r<256;r++,n^=mul2(n))e[r]=n;const t=new Uint8Array(256);t[0]=99;for(let r=0;r<255;r++){let n=e[255-r];n|=n<<8,t[e[r]]=255&(n^n>>4^n>>5^n>>6^n>>7^99)}return clean(e),t})(),invSbox=sbox.map((e,t)=>sbox.indexOf(t)),rotr32_8=e=>e<<24|e>>>8,rotl32_8=e=>e<<8|e>>>24;function genTtable(e,t){if(256!==e.length)throw new Error("Wrong sbox length");const r=new Uint32Array(256).map((r,n)=>t(e[n])),n=r.map(rotl32_8),i=n.map(rotl32_8),o=i.map(rotl32_8),s=new Uint32Array(65536),a=new Uint32Array(65536),l=new Uint16Array(65536);for(let c=0;c<256;c++)for(let t=0;t<256;t++){const u=256*c+t;s[u]=r[c]^n[t],a[u]=i[c]^o[t],l[u]=e[c]<<8|e[t]}return{sbox:e,sbox2:l,T0:r,T1:n,T2:i,T3:o,T01:s,T23:a}}const tableEncoding=genTtable(sbox,e=>mul(e,3)<<24|e<<16|e<<8|mul(e,2)),tableDecoding=genTtable(invSbox,e=>mul(e,11)<<24|mul(e,13)<<16|mul(e,9)<<8|mul(e,14)),xPowers=(()=>{const e=new Uint8Array(16);for(let t=0,r=1;t<16;t++,r=mul2(r))e[t]=r;return e})();function expandKeyLE(e){abytes(e);const t=e.length;validateKeyLength(e);const{sbox2:r}=tableEncoding,n=[];isAligned32$1(e)||n.push(e=copyBytes(e));const i=u32(e),o=i.length,s=e=>applySbox(r,e,e,e,e),a=new Uint32Array(t+28);a.set(i);for(let l=o;l<a.length;l++){let e=a[l-1];l%o===0?e=s(rotr32_8(e))^xPowers[l/o-1]:o>6&&l%o===4&&(e=s(e)),a[l]=a[l-o]^e}return clean(...n),a}function expandKeyDecLE(e){const t=expandKeyLE(e),r=t.slice(),n=t.length,{sbox2:i}=tableEncoding,{T0:o,T1:s,T2:a,T3:l}=tableDecoding;for(let c=0;c<n;c+=4)for(let e=0;e<4;e++)r[c+e]=t[n-c-4+e];clean(t);for(let c=4;c<n-4;c++){const e=r[c],t=applySbox(i,e,e,e,e);r[c]=o[255&t]^s[t>>>8&255]^a[t>>>16&255]^l[t>>>24]}return r}function apply0123(e,t,r,n,i,o){return e[r<<8&65280|n>>>8&255]^t[i>>>8&65280|o>>>24&255]}function applySbox(e,t,r,n,i){return e[255&t|65280&r]|e[n>>>16&255|i>>>16&65280]<<16}function encrypt$1(e,t,r,n,i){const{sbox2:o,T01:s,T23:a}=tableEncoding;let l=0;t^=e[l++],r^=e[l++],n^=e[l++],i^=e[l++];const c=e.length/4-2;for(let u=0;u<c;u++){const o=e[l++]^apply0123(s,a,t,r,n,i),c=e[l++]^apply0123(s,a,r,n,i,t),u=e[l++]^apply0123(s,a,n,i,t,r),h=e[l++]^apply0123(s,a,i,t,r,n);t=o,r=c,n=u,i=h}return{s0:e[l++]^applySbox(o,t,r,n,i),s1:e[l++]^applySbox(o,rLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{--default-list-color: #fc3;--hover-bg: #f8f9fa;--sidebar-width: 350px;--link-color: #2a7fff;--link-color-visited: #6a4fbf;--marker-color-primary: #ea4335;--marker-color-dark: #b31412;--danger-color: var(--marker-color-primary);--danger-color-dark: var(--marker-color-dark)}html,body{height:100%;overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}button{-webkit-tap-highlight-color:transparent}body{margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400;color:#333}#root,#ember-testing{height:100%}.map-container{background:#f8f9fa;-webkit-tap-highlight-color:transparent;outline:none;position:absolute;inset:0}#rs-widget-container{position:absolute;top:60px;right:10px;z-index:4000;display:none}#rs-widget-container.visible{display:block}.rs-backdrop{position:fixed;inset:0;z-index:3999}.app-header{position:absolute;top:0;left:0;right:0;height:60px;padding:.5rem 1rem;z-index:3000;pointer-events:none;display:grid;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem}@media(max-width:768px){.app-header{padding:.5rem .5rem 0;height:auto;grid-template-columns:1fr auto;grid-template-areas:"search user" "chips chips";row-gap:8px}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{display:flex;align-items:center;grid-area:search;justify-self:start;width:100%}@media not (max-width:768px){.header-left{min-width:300px;max-width:400px}}@media not (max-width:768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;display:flex;justify-content:center;min-width:0}@media not (max-width:768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media(max-width:768px){.header-center{width:100%;overflow:hidden;justify-content:start}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{background:none;border:none;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}.user-avatar-placeholder{width:40px;height:40px;background:#2a3743;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}.user-avatar{width:40px;height:40px;border-radius:50%;object-fit:cover;flex-shrink:0;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{border:2px dashed #ccc;border-radius:8px;text-align:center;transition:all .2s ease;margin:1.5rem 0 1rem;background-color:#ffffff05;cursor:pointer;aspect-ratio:4 / 3}.dropzone.is-dragging{border-color:#61afef;background-color:#61afef0d}.dropzone-label{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;cursor:pointer;color:#898989;width:100%;height:100%}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-grid{display:flex;flex-direction:column;gap:12px;margin-bottom:20px}.photo-upload-item{position:relative;aspect-ratio:4 / 3;border-radius:6px;overflow:hidden;background:#1e262e;width:100%}.photo-upload-item img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;display:block;z-index:1}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{position:absolute;inset:0;background:#0009;display:flex;flex-direction:column;align-items:center;justify-content:center}.upload-status-text{color:#fff;margin-top:1rem;font-size:.9rem;text-shadow:0 1px 3px rgb(0 0 0 / 80%);text-align:center;padding:0 1rem}.photo-upload-item .error-overlay{background:#e06c75cc;cursor:pointer;border:none;padding:0;margin:0;width:100%}.photo-upload-item .btn-remove-photo{position:absolute;top:4px;right:4px;background:#000000b3;border:none;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#fff;padding:0}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{position:absolute;top:100%;right:0;margin-top:10px;width:280px;background:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;padding:1rem;z-index:3001}.menu-backdrop{position:fixed;inset:0;z-index:3000}.account-list{list-style:none;padding:0;margin:0}.account-item{display:flex;flex-direction:column;padding:1rem 0;border-bottom:1px solid #eee}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{display:flex;justify-content:space-between;align-items:center}.account-status{font-size:.85rem;color:#898989;margin-top:.35rem;margin-left:calc(18px + .75rem);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-iLine truncated
|
||||||
@@ -1 +0,0 @@
|
|||||||
:root{--default-list-color: #fc3;--hover-bg: #f8f9fa;--sidebar-width: 350px;--link-color: #2a7fff;--link-color-visited: #6a4fbf;--marker-color-primary: #ea4335;--marker-color-dark: #b31412}html,body{height:100%;overscroll-behavior:none;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}button{-webkit-tap-highlight-color:transparent}body{margin:0;font-family:Noto Sans,sans-serif;font-size:16px;color:#333}#root,#ember-testing{height:100%}.map-container{background:#f8f9fa;-webkit-tap-highlight-color:transparent;outline:none;position:absolute;inset:0}#rs-widget-container{position:absolute;top:60px;right:10px;z-index:4000;display:none}#rs-widget-container.visible{display:block}.rs-backdrop{position:fixed;inset:0;z-index:3999}.app-header{position:absolute;top:0;left:0;right:0;height:60px;padding:.5rem 1rem;z-index:3000;pointer-events:none;display:grid;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem}@media(max-width:768px){.app-header{padding:.5rem .5rem 0;height:auto;grid-template-columns:1fr auto;grid-template-areas:"search user" "chips chips";row-gap:8px}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{display:flex;align-items:center;grid-area:search;justify-self:start;width:100%}@media not (max-width:768px){.header-left{min-width:300px;max-width:400px}}@media not (max-width:768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;display:flex;justify-content:center;min-width:0}@media not (max-width:768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media(max-width:768px){.header-center{width:100%;overflow:hidden;justify-content:start}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{background:none;border:none;cursor:pointer;padding:0}.user-avatar-placeholder{width:40px;height:40px;background:#2a3743;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px #0003}.user-menu-container{position:relative}.user-menu-popover{position:absolute;top:100%;right:0;margin-top:10px;width:280px;background:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;padding:1rem;z-index:3001}.menu-backdrop{position:fixed;inset:0;z-index:3000}.account-list{list-style:none;padding:0;margin:0}.account-item{display:flex;flex-direction:column;padding:1rem 0;border-bottom:1px solid #eee}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{display:flex;justify-content:space-between;align-items:center}.account-status{font-size:.85rem;font-weight:700;color:#898989;margin-top:.35rem;margin-left:calc(18px + .75rem)}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{display:flex;align-items:center;gap:.75rem;font-weight:500}.btn-text{background:none;border:none;padding:0;font-weight:600;cursor:pointer;font-size:.9rem}.text-primary{color:var(--link-color)}.text-danger{color:#dc3545}.btn-text:hover{text-decoration:underline}.sidebar{position:absolute;top:0;left:0;bottom:0;width:var(--sidebar-width);background:#fff;z-index:3100;box-shadow:2px 0 5px #0000001a;display:flex;flex-direction:column;overflow:hidden}.sidebar.app-menu-pane{z-index:3200}@media(max-width:768px){.sidebar.app-menu-pane{width:100%;right:0;border-radius:16px 16px 0 0;height:66vh;top:auto;bottom:0}}.sidebar-header{padding:1rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}.sidebar-header h2{margin:0;font-size:1.2rem;display:flex;align-items:center;gap:.5rem}.sidebar-content{padding:1rem;overflow-y:auto;-webkit-overflow-scrolling:touch;flex:1;min-height:0;touch-action:pan-y;overscroll-behavior:contain}.app-menu-pane .sidebar-content{padding-top:.5rem}.app-menu{list-style:none;padding:0;margin:0 -1rem}.app-menu button{width:100%;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;background:none;border:none;color:#333;cursor:pointer;text-align:left;font-size:.95rem;font-family:inherit;transition:background-color .2s}.app-menu button:hover{background-color:var(--hover-bg)}.app-menu .icon{color:#666;width:20px;height:20px}.sidebar-content details{margin:0 -1rem}.sidebar-content details summary{list-style:none;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;cursor:pointer;font-size:.95rem;color:#333;transition:background-color .2s}.sidebar-content details summary::-webkit-details-marker{display:none}.sidebar-content details summary:hover{background-color:var(--hover-bg)}.sidebar-content details summary .icon{width:20px;height:20px}.sidebar-content details summary:after{content:"";width:20px;height:20px;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-Line truncated
|
|
||||||
@@ -1 +1 @@
|
|||||||
import{c as e,g as a,a as s,s as t,A as r,C as o,b as n,D as i,L as l,N as b,d as c,P as p,e as d,S as m,T as u,_ as g,f as y,h as _,i as O,j as S,k as f,l as C,m as P,n as h,o as v,p as j,q as E,r as M,t as D,u as N,v as V,w as A,x as w,y as T,z as I,B as x,E as R,F as k,G as F,H as L,I as z,J as G,K as U,M as B,O as W,Q as Y,R as H,U as J,V as K,W as X,X as q,Y as Q,Z,$,a0 as ee,a1 as ae,a2 as se,a3 as te,a4 as re,a5 as oe,a6 as ne,a7 as ie,a8 as le,a9 as be,aa as pe,ab as de,ac as me,ad as ue,ae as ge,af as ye,ag as _e,ah as Oe,ai as Se,aj as fe,ak as Ce,al as Pe,am as he,an as ve,ao as je,ap as Ee,aq as Me,ar as De,as as Ne,at as Ve,au as Ae,av as we,aw as Te,ax as Ie,ay as xe,az as Re,aA as ke,aB as Fe,aC as Le,aD as ze,aE as Ge,aF as Ue,aG as Be,aH as We,aI as Ye,aJ as He,aK as Je,aL as Ke,aM as Xe,aN as qe,aO as Qe,aP as Ze,aQ as $e,aR as ea,aS as aa,aT as sa,aU as ta,aV as ra,aW as oa,aX as na,aY as ia,aZ as la,a_ as ba,a$ as pa,b0 as da,b1 as ma,b2 as ua,b3 as ga,b4 as ya,b5 as _a,b6 as Oa,b7 as Sa,b8 as fa,b9 as Ca,ba as Pa,bb as ha,bc as va,bd as ja,be as Ea,bf as Ma,bg as Da,bh as Na,bi as Va,bj as Aa,bk as wa,bl as Ta,bm as Ia,bn as xa,bo as Ra,bp as ka,bq as Fa,br as La,bs as za,bt as Ga,bu as Ua,bv as Ba,bw as Wa,bx as Ya,by as Ha,bz as Ja,bA as Ka,bB as Xa,bC as qa,bD as Qa,bE as Za,bF as $a,bG as es,bH as as,bI as ss,bJ as ts,bK as rs,bL as os,bM as ns,bN as is,bO as ls,bP as bs,bQ as cs,bR as ps,bS as ds,bT as ms,bU as us}from"./main-18-jE9H3.js";export{bV as Application,bW as ApplicationNamespace,bX as Array,bY as ArrayProxy,bZ as Controller,b_ as Debug,b$ as EmberDestroyable,c0 as EmberObject,c1 as EnumerableMutable,cc as GlimmerComponent,cd as GlimmerManager,ce as GlimmerReference,cf as GlimmerRuntime,cg as GlimmerUtil,ch as GlimmerValidator,c2 as Instrumentation,c0 as Object,c3 as ObjectCore,c4 as ObjectEvented,c5 as ObjectObservable,c6 as ObjectPromiseProxyMixin,c7 as ObjectProxy,ca as Owner,c8 as Runloop,c9 as Service,cb as VERSION}from"./main-18-jE9H3.js";const gs=new WeakMap,ys=Object.freeze(Object.defineProperty({__proto__:null,ASYNC_OBSERVERS:r,ComputedDescriptor:o,ComputedProperty:n,DEBUG_INJECTION_FUNCTIONS:i,Libraries:l,NAMESPACES:b,NAMESPACES_BY_ID:c,PROPERTY_DID_CHANGE:p,PROXY_CONTENT:d,SYNC_OBSERVERS:m,TrackedDescriptor:u,_getPath:g,_getProp:y,_setProp:_,activateObserver:O,addArrayObserver:S,addListener:f,addNamespace:C,addObserver:P,alias:h,arrayContentDidChange:v,arrayContentWillChange:j,autoComputed:E,beginPropertyChanges:M,cached:(...s)=>{const[t,r,o]=s,n=new WeakMap,i=o.get;o.get=function(){return n.has(this)||n.set(this,e(i.bind(this))),a(n.get(this))}},changeProperties:D,computed:N,createCache:e,defineDecorator:V,defineProperty:A,defineValue:w,deprecateProperty:function(e,a,r){Object.defineProperty(e,a,{configurable:!0,enumerable:!1,set(e){t(this,r,e)},get(){return s(this,r)}})},descriptorForDecorator:T,descriptorForProperty:I,eachProxyArrayDidChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayDidChange(e,a,s,t)},eachProxyArrayWillChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayWillChange(e,a,s,t)},endPropertyChanges:x,expandProperties:R,findNamespace:k,findNamespaces:F,flushAsyncObservers:L,get:s,getCachedValueFor:z,getProperties:G,getValue:a,hasListeners:U,hasUnknownProperty:B,inject:W,isClassicDecorator:Y,isComputed:H,isConst:J,isElementDescriptor:K,isNamespaceSearchDisabled:X,libraries:q,makeComputedDecorator:Q,markObjectAsDirty:Z,nativeDescDecorator:$,notifyPropertyChange:ee,objectAt:ae,on:se,processAllNamespaces:te,processNamespace:re,removeArrayObserver:oe,removeListener:ne,removeNamespace:ie,removeObserver:le,replace:be,replaceInNativeArray:pe,revalidateObservers:de,sendEvent:me,set:t,setClassicDecorator:ue,setNamespaceSearchDisabled:ge,setProperties:ye,setUnprocessedMixins:_e,tagForObject:Oe,tagForProperty:Se,tracked:fe,trySet:Ce},Symbol.toStringTag,{value:"Module"})),_s=Object.freeze(Object.defineProperty({__proto__:null,Input:Pe,Textarea:he,capabilities:ve,default:je,getComponentTemplate:Ee,setComponentManager:Me,setComponentTemplate:De},Symbol.toStringTag,{value:"Module"})),Os=Object.freeze(Object.defineProperty({__proto__:null,cacheFor:z,guidFor:Ne},Symbol.toStringTag,{value:"Module"})),Ss=Object.freeze(Object.defineProperty({__proto__:null,EventTarget:Ve,Promise:Ae,all:we,allSettled:Te,asap:Ie,async:xe,cast:Re,configure:ke,default:Fe,defer:Le,denodeify:ze,filter:Ge,hash:Ue,hashSettled:Be,map:We,off:Ye,on:He,race:Je,reject:Ke,resolve:Xe,rethrow:qe},Symbol.toStringTag,{value:"Module"})),fs=Object.freeze(Object.defineProperty({__proto__:null,default:Qe},Symbol.toStringTag,{value:"Module"})),Cs=Object.freeze(Object.defineProperty({__proto__:null,ENV:Ze,context:$e,getENV:ea,getLookup:aa,global:sa,setLookup:ta},Symbol.toStringTag,{value:"Module"})),Ps=Object.freeze(Object.defineProperty({__proto__:null,Meta:ra,UNDEFINED:oa,counters:na,meta:ia,peekMeta:la,setMeta:ba},Symbol.toStringTag,{value:"Module"})),hs=Object.freeze(Object.definePropLine truncated
|
import{c as e,g as a,a as s,s as t,A as r,C as o,b as n,D as i,L as l,N as b,d as c,P as p,e as d,S as m,T as u,_ as g,f as y,h as _,i as O,j as S,k as f,l as C,m as P,n as h,o as v,p as j,q as E,r as M,t as D,u as N,v as V,w as A,x as w,y as T,z as I,B as x,E as R,F as k,G as F,H as L,I as z,J as G,K as U,M as B,O as W,Q as Y,R as H,U as J,V as K,W as X,X as q,Y as Q,Z,$,a0 as ee,a1 as ae,a2 as se,a3 as te,a4 as re,a5 as oe,a6 as ne,a7 as ie,a8 as le,a9 as be,aa as pe,ab as de,ac as me,ad as ue,ae as ge,af as ye,ag as _e,ah as Oe,ai as Se,aj as fe,ak as Ce,al as Pe,am as he,an as ve,ao as je,ap as Ee,aq as Me,ar as De,as as Ne,at as Ve,au as Ae,av as we,aw as Te,ax as Ie,ay as xe,az as Re,aA as ke,aB as Fe,aC as Le,aD as ze,aE as Ge,aF as Ue,aG as Be,aH as We,aI as Ye,aJ as He,aK as Je,aL as Ke,aM as Xe,aN as qe,aO as Qe,aP as Ze,aQ as $e,aR as ea,aS as aa,aT as sa,aU as ta,aV as ra,aW as oa,aX as na,aY as ia,aZ as la,a_ as ba,a$ as pa,b0 as da,b1 as ma,b2 as ua,b3 as ga,b4 as ya,b5 as _a,b6 as Oa,b7 as Sa,b8 as fa,b9 as Ca,ba as Pa,bb as ha,bc as va,bd as ja,be as Ea,bf as Ma,bg as Da,bh as Na,bi as Va,bj as Aa,bk as wa,bl as Ta,bm as Ia,bn as xa,bo as Ra,bp as ka,bq as Fa,br as La,bs as za,bt as Ga,bu as Ua,bv as Ba,bw as Wa,bx as Ya,by as Ha,bz as Ja,bA as Ka,bB as Xa,bC as qa,bD as Qa,bE as Za,bF as $a,bG as es,bH as as,bI as ss,bJ as ts,bK as rs,bL as os,bM as ns,bN as is,bO as ls,bP as bs,bQ as cs,bR as ps,bS as ds,bT as ms,bU as us}from"./main-CLZV93ov.js";export{bV as Application,bW as ApplicationNamespace,bX as Array,bY as ArrayProxy,bZ as Controller,b_ as Debug,b$ as EmberDestroyable,c0 as EmberObject,c1 as EnumerableMutable,cc as GlimmerComponent,cd as GlimmerManager,ce as GlimmerReference,cf as GlimmerRuntime,cg as GlimmerUtil,ch as GlimmerValidator,c2 as Instrumentation,c0 as Object,c3 as ObjectCore,c4 as ObjectEvented,c5 as ObjectObservable,c6 as ObjectPromiseProxyMixin,c7 as ObjectProxy,ca as Owner,c8 as Runloop,c9 as Service,cb as VERSION}from"./main-CLZV93ov.js";const gs=new WeakMap,ys=Object.freeze(Object.defineProperty({__proto__:null,ASYNC_OBSERVERS:r,ComputedDescriptor:o,ComputedProperty:n,DEBUG_INJECTION_FUNCTIONS:i,Libraries:l,NAMESPACES:b,NAMESPACES_BY_ID:c,PROPERTY_DID_CHANGE:p,PROXY_CONTENT:d,SYNC_OBSERVERS:m,TrackedDescriptor:u,_getPath:g,_getProp:y,_setProp:_,activateObserver:O,addArrayObserver:S,addListener:f,addNamespace:C,addObserver:P,alias:h,arrayContentDidChange:v,arrayContentWillChange:j,autoComputed:E,beginPropertyChanges:M,cached:(...s)=>{const[t,r,o]=s,n=new WeakMap,i=o.get;o.get=function(){return n.has(this)||n.set(this,e(i.bind(this))),a(n.get(this))}},changeProperties:D,computed:N,createCache:e,defineDecorator:V,defineProperty:A,defineValue:w,deprecateProperty:function(e,a,r){Object.defineProperty(e,a,{configurable:!0,enumerable:!1,set(e){t(this,r,e)},get(){return s(this,r)}})},descriptorForDecorator:T,descriptorForProperty:I,eachProxyArrayDidChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayDidChange(e,a,s,t)},eachProxyArrayWillChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayWillChange(e,a,s,t)},endPropertyChanges:x,expandProperties:R,findNamespace:k,findNamespaces:F,flushAsyncObservers:L,get:s,getCachedValueFor:z,getProperties:G,getValue:a,hasListeners:U,hasUnknownProperty:B,inject:W,isClassicDecorator:Y,isComputed:H,isConst:J,isElementDescriptor:K,isNamespaceSearchDisabled:X,libraries:q,makeComputedDecorator:Q,markObjectAsDirty:Z,nativeDescDecorator:$,notifyPropertyChange:ee,objectAt:ae,on:se,processAllNamespaces:te,processNamespace:re,removeArrayObserver:oe,removeListener:ne,removeNamespace:ie,removeObserver:le,replace:be,replaceInNativeArray:pe,revalidateObservers:de,sendEvent:me,set:t,setClassicDecorator:ue,setNamespaceSearchDisabled:ge,setProperties:ye,setUnprocessedMixins:_e,tagForObject:Oe,tagForProperty:Se,tracked:fe,trySet:Ce},Symbol.toStringTag,{value:"Module"})),_s=Object.freeze(Object.defineProperty({__proto__:null,Input:Pe,Textarea:he,capabilities:ve,default:je,getComponentTemplate:Ee,setComponentManager:Me,setComponentTemplate:De},Symbol.toStringTag,{value:"Module"})),Os=Object.freeze(Object.defineProperty({__proto__:null,EventTarget:Ne,Promise:Ve,all:Ae,allSettled:we,asap:Te,async:Ie,cast:xe,configure:Re,default:ke,defer:Fe,denodeify:Le,filter:ze,hash:Ge,hashSettled:Ue,map:Be,off:We,on:Ye,race:He,reject:Je,resolve:Ke,rethrow:Xe},Symbol.toStringTag,{value:"Module"})),Ss=Object.freeze(Object.defineProperty({__proto__:null,cacheFor:z,guidFor:qe},Symbol.toStringTag,{value:"Module"})),fs=Object.freeze(Object.defineProperty({__proto__:null,default:Qe},Symbol.toStringTag,{value:"Module"})),Cs=Object.freeze(Object.defineProperty({__proto__:null,ENV:Ze,context:$e,getENV:ea,getLookup:aa,global:sa,setLookup:ta},Symbol.toStringTag,{value:"Module"})),Ps=Object.freeze(Object.defineProperty({__proto__:null,Meta:ra,UNDEFINED:oa,counters:na,meta:ia,peekMeta:la,setMeta:ba},Symbol.toStringTag,{value:"Module"})),hs=Object.freeze(Object.definePropLine truncated
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import{ci as t,cj as e,ck as i,cl as r,cm as n,cn as s,co as o,cp as a}from"./main-CLZV93ov.js";var h=Array.isArray;function u(){for(var r,n=[],s=0;s<arguments.length;s++)n[s]=arguments[s];return 1===(n=1===(r=n).length&&h(r[0])?r[0]:r).length?t(n[0]):new e(function(e){return function(r){for(var n=[],s=function(s){n.push(t(e[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},o=0;n&&!r.closed&&o<e.length;o++)s(o)}}
|
||||||
|
/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */(n))}function l(t,...e){if(!((i=t)instanceof Uint8Array||ArrayBuffer.isView(i)&&"Uint8Array"===i.constructor.name))throw new Error("Uint8Array expected");var i;if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function c(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function d(...t){for(let e=0;e<t.length;e++)t[e].fill(0)}function f(t){return new DataView(t.buffer,t.byteOffset,t.byteLength)}function g(t,e){return t<<32-e|t>>>e}function p(t){return"string"==typeof t&&(t=function(t){if("string"!=typeof t)throw new Error("string expected");return new Uint8Array((new TextEncoder).encode(t))}(t)),l(t),t}class m{}function w(t){const e=e=>t().update(p(e)).digest(),i=t();return e.outputLen=i.outputLen,e.blockLen=i.blockLen,e.create=()=>t(),e}function b(t,e,i){return t&e^~t&i}function y(t,e,i){return t&e^t&i^e&i}class E extends m{constructor(t,e,i,r){super(),this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.blockLen=t,this.outputLen=e,this.padOffset=i,this.isLE=r,this.buffer=new Uint8Array(t),this.view=f(this.buffer)}update(t){c(this),l(t=p(t));const{view:e,buffer:i,blockLen:r}=this,n=t.length;for(let s=0;s<n;){const o=Math.min(r-this.pos,n-s);if(o===r){const e=f(t);for(;r<=n-s;s+=r)this.process(e,s);continue}i.set(t.subarray(s,s+o),this.pos),this.pos+=o,s+=o,this.pos===r&&(this.process(e,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){c(this),function(t,e){l(t);const i=e.outputLen;if(t.length<i)throw new Error("digestInto() expects output buffer of length at least "+i)}(t,this),this.finished=!0;const{buffer:e,view:i,blockLen:r,isLE:n}=this;let{pos:s}=this;e[s++]=128,d(this.buffer.subarray(s)),this.padOffset>r-s&&(this.process(i,0),s=0);for(let l=s;l<r;l++)e[l]=0;!function(t,e,i,r){if("function"==typeof t.setBigUint64)return t.setBigUint64(e,i,r);const n=BigInt(32),s=BigInt(4294967295),o=Number(i>>n&s),a=Number(i&s),h=r?4:0,u=r?0:4;t.setUint32(e+h,o,r),t.setUint32(e+u,a,r)}(i,r-8,BigInt(8*this.length),n),this.process(i,0);const o=f(t),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const h=a/4,u=this.get();if(h>u.length)throw new Error("_sha2: outputLen bigger than state");for(let l=0;l<h;l++)o.setUint32(4*l,u[l],n)}digest(){const{buffer:t,outputLen:e}=this;this.digestInto(t);const i=t.slice(0,e);return this.destroy(),i}_cloneInto(t){t||(t=new this.constructor),t.set(...this.get());const{blockLen:e,buffer:i,length:r,finished:n,destroyed:s,pos:o}=this;return t.destroyed=s,t.finished=n,t.length=r,t.pos=o,r%e&&t.buffer.set(i),t}clone(){return this._cloneInto()}}const _=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),x=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),A=new Uint32Array(64);class L extends E{constructor(t=32){super(64,t,8,!1),this.A=0|_[0],this.B=0|_[1],this.C=0|_[2],this.D=0|_[3],this.E=0|_[4],this.F=0|_[5],this.G=0|_[6],this.H=0|_[7]}get(){const{A:t,B:e,C:i,D:r,E:n,F:s,G:o,H:a}=this;return[t,e,i,r,n,s,o,a]}set(t,e,i,r,n,s,o,a){this.A=0|t,this.B=0|e,this.C=0|i,this.D=0|r,this.E=0|n,this.F=0|s,this.G=0|o,this.H=0|a}process(t,e){for(let l=0;l<16;l++,e+=4)A[l]=t.getUint32(e,!1);for(let l=16;l<64;l++){const t=A[l-15],e=A[l-2],i=g(t,7)^g(t,18)^t>>>3,r=g(e,17)^g(e,19)^e>>>10;A[l]=r+A[l-7]+i+A[l-16]|0}let{A:i,B:r,C:n,D:s,E:o,F:a,G:h,H:u}=this;for(let l=0;l<64;l++){const t=u+(g(o,6)^g(o,11)^g(o,25))+b(o,a,h)+x[l]+A[l]|0,e=(g(i,2)^g(i,13)^g(i,22))+y(i,r,n)|0;u=h,h=a,a=o,o=s+t|0,s=n,n=r,r=i,i=t+e|0}i=i+this.A|0,r=r+this.B|0,n=n+this.C|0,s=s+this.D|0,o=o+this.E|0,a=a+this.F|0,h=h+this.G|0,u=u+this.H|0,this.set(i,r,n,s,o,a,h,u)}roundClean(){d(A)}destroy(){this.set(0,0,0,0,0,0,0,0),d(this.buffer)}}const U=w(()=>new L),I=32;class B{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(t){if(t instanceof B&&(t=t.unwrap()),"number"!=typeof t.length)throw Error("bad length");const e=t.length+this.length;if(this.capacity<e){const t=this._raw,i=Math.max(2*this.capacity,e)Line truncated
|
||||||
+3
-2
@@ -39,9 +39,10 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-18-jE9H3.js"></script>
|
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-uF6fmHZ4.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="modal-portal"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,6 +5,16 @@
|
|||||||
"source": "nullvoxpopuli/agent-skills",
|
"source": "nullvoxpopuli/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
|
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
|
||||||
|
},
|
||||||
|
"nak": {
|
||||||
|
"source": "soapbox-pub/nostr-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"source": "soapbox-pub/nostr-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import { module, test } from 'qunit';
|
|||||||
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import sinon from 'sinon';
|
|
||||||
|
|
||||||
module('Acceptance | map search reset', function (hooks) {
|
module('Acceptance | map search reset', function (hooks) {
|
||||||
setupApplicationTest(hooks);
|
setupApplicationTest(hooks);
|
||||||
@@ -17,58 +16,10 @@ module('Acceptance | map search reset', function (hooks) {
|
|||||||
'marco:map-view',
|
'marco:map-view',
|
||||||
JSON.stringify(highZoomState)
|
JSON.stringify(highZoomState)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stub window.fetch using Sinon
|
|
||||||
// We want to intercept map style requests and let everything else through
|
|
||||||
this.fetchStub = sinon.stub(window, 'fetch');
|
|
||||||
|
|
||||||
this.fetchStub.callsFake(async (input, init) => {
|
|
||||||
let url = input;
|
|
||||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
|
||||||
url = input.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof url === 'string' &&
|
|
||||||
url.includes('tiles.openfreemap.org/styles/liberty')
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
version: 8,
|
|
||||||
name: 'Liberty',
|
|
||||||
sources: {
|
|
||||||
openmaptiles: {
|
|
||||||
type: 'vector',
|
|
||||||
url: 'https://tiles.openfreemap.org/planet',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'background',
|
|
||||||
type: 'background',
|
|
||||||
paint: {
|
|
||||||
'background-color': '#123456',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
glyphs:
|
|
||||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
|
||||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass through to the original implementation
|
|
||||||
return this.fetchStub.wrappedMethod(input, init);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hooks.afterEach(function () {
|
hooks.afterEach(function () {
|
||||||
window.localStorage.removeItem('marco:map-view');
|
window.localStorage.removeItem('marco:map-view');
|
||||||
// Restore the original fetch
|
|
||||||
this.fetchStub.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking the map clears the category search parameter', async function (assert) {
|
test('clicking the map clears the category search parameter', async function (assert) {
|
||||||
@@ -140,12 +91,14 @@ module('Acceptance | map search reset', function (hooks) {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for transition to index
|
// Wait for transition or UI update
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
assert.strictEqual(
|
|
||||||
currentURL(),
|
// Sidebar should be hidden, but we should stay on the search route
|
||||||
'/',
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
'Should have transitioned to index (closed sidebar)'
|
assert.ok(
|
||||||
|
currentURL().includes('category=coffee'),
|
||||||
|
'Should have stayed on the search route with markers intact'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Second Click (Start new search)
|
// Second Click (Start new search)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class MockOsmService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockStorageService extends Service {
|
class MockStorageService extends Service {
|
||||||
|
initialSyncDone = true;
|
||||||
savedPlaces = [];
|
savedPlaces = [];
|
||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
@@ -95,8 +96,8 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
// Click the Close (X) button
|
// Click the Close (X) button
|
||||||
await click('.close-btn');
|
await click('.close-btn');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import { Promise } from 'rsvp';
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
let photonResolve;
|
||||||
|
let osmResolve;
|
||||||
|
|
||||||
class MockPhotonService extends Service {
|
class MockPhotonService extends Service {
|
||||||
cancelAll() {}
|
cancelAll() {}
|
||||||
|
|
||||||
async search(query) {
|
async search(query) {
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
if (query === 'slow') {
|
if (query === 'slow') {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
// Return a promise that we can manually resolve in the test
|
||||||
|
// to avoid race conditions with native setTimeout
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
photonResolve = () => {
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
|
|||||||
cancelAll() {}
|
cancelAll() {}
|
||||||
|
|
||||||
async getCategoryPois(bounds, category) {
|
async getCategoryPois(bounds, category) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
if (category === 'slow_category') {
|
if (category === 'slow_category') {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
return new Promise((resolve) => {
|
||||||
|
osmResolve = () => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
setupApplicationTest(hooks);
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
|
photonResolve = null;
|
||||||
|
osmResolve = null;
|
||||||
this.owner.register('service:photon', MockPhotonService);
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
this.owner.register('service:osm', MockOsmService);
|
this.owner.register('service:osm', MockOsmService);
|
||||||
});
|
});
|
||||||
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is set for text search'
|
'Loading state is set for text search'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve the manual promise so the task can finish deterministically
|
||||||
|
photonResolve();
|
||||||
|
|
||||||
await searchPromise;
|
await searchPromise;
|
||||||
|
await settled(); // Wait for ember-concurrency tasks to fully settle
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
mapUi.loadingState,
|
mapUi.loadingState,
|
||||||
null,
|
null,
|
||||||
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is set for category search'
|
'Loading state is set for category search'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve the manual promise
|
||||||
|
osmResolve();
|
||||||
|
|
||||||
await catPromise;
|
await catPromise;
|
||||||
|
await settled();
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
mapUi.loadingState,
|
mapUi.loadingState,
|
||||||
null,
|
null,
|
||||||
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
|
|
||||||
// 4. Click the clear button (should be visible since input has value)
|
// 4. Click the clear button (should be visible since input has value)
|
||||||
await click('.search-clear-btn');
|
await click('.search-clear-btn');
|
||||||
|
// Wait for the click and transition to settle
|
||||||
|
|
||||||
// Verify loading state is cleared immediately
|
// Verify loading state is cleared immediately
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@@ -130,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
'Loading state is cleared immediately after clicking clear'
|
'Loading state is cleared immediately after clicking clear'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up the dangling promise
|
||||||
|
if (photonResolve) {
|
||||||
|
photonResolve();
|
||||||
|
}
|
||||||
|
|
||||||
// Verify we are back on index (or at least query is gone)
|
// Verify we are back on index (or at least query is gone)
|
||||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
setupRenderingTest as upstreamSetupRenderingTest,
|
setupRenderingTest as upstreamSetupRenderingTest,
|
||||||
setupTest as upstreamSetupTest,
|
setupTest as upstreamSetupTest,
|
||||||
} from 'ember-qunit';
|
} from 'ember-qunit';
|
||||||
|
import { setupNostrMocks } from './mock-nostr';
|
||||||
|
|
||||||
// This file exists to provide wrappers around ember-qunit's
|
// This file exists to provide wrappers around ember-qunit's
|
||||||
// test setup functions. This way, you can easily extend the setup that is
|
// test setup functions. This way, you can easily extend the setup that is
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
|
|
||||||
function setupApplicationTest(hooks, options) {
|
function setupApplicationTest(hooks, options) {
|
||||||
upstreamSetupApplicationTest(hooks, options);
|
upstreamSetupApplicationTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for application tests can be done here.
|
// Additional setup for application tests can be done here.
|
||||||
//
|
//
|
||||||
@@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) {
|
|||||||
|
|
||||||
function setupRenderingTest(hooks, options) {
|
function setupRenderingTest(hooks, options) {
|
||||||
upstreamSetupRenderingTest(hooks, options);
|
upstreamSetupRenderingTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for rendering tests can be done here.
|
// Additional setup for rendering tests can be done here.
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTest(hooks, options) {
|
function setupTest(hooks, options) {
|
||||||
upstreamSetupTest(hooks, options);
|
upstreamSetupTest(hooks, options);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
// Additional setup for unit tests can be done here.
|
// Additional setup for unit tests can be done here.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
export class MockNostrAuthService extends Service {
|
||||||
|
@tracked pubkey = null;
|
||||||
|
@tracked signerType = null;
|
||||||
|
@tracked connectStatus = null;
|
||||||
|
@tracked connectUri = null;
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithExtension() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWithApp() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockNostrDataService extends Service {
|
||||||
|
@tracked profile = null;
|
||||||
|
@tracked mailboxes = null;
|
||||||
|
@tracked blossomServers = [];
|
||||||
|
@tracked placePhotos = [];
|
||||||
|
|
||||||
|
store = {
|
||||||
|
add: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
get activeReadRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeWriteRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultReadRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultWriteRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get userDisplayName() {
|
||||||
|
return 'Mock User';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPhotosForPlace() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlacePhotos() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockNostrRelayService extends Service {
|
||||||
|
pool = {
|
||||||
|
publish: () => Promise.resolve([{ ok: true }]),
|
||||||
|
subscribe: () => {},
|
||||||
|
unsubscribe: () => {},
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
async publish() {
|
||||||
|
return [{ ok: true }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupNostrMocks(hooks) {
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||||
|
this.owner.register('service:nostrData', MockNostrDataService);
|
||||||
|
this.owner.register('service:nostrRelay', MockNostrRelayService);
|
||||||
|
});
|
||||||
|
}
|
||||||
+6
-1
@@ -16,7 +16,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/testem.js" integrity="" data-embroider-ignore></script>
|
<script
|
||||||
|
src="/testem.js"
|
||||||
|
integrity=""
|
||||||
|
data-embroider-ignore
|
||||||
|
vite-ignore
|
||||||
|
></script>
|
||||||
|
|
||||||
<script type="module">import "ember-testing";</script>
|
<script type="module">import "ember-testing";</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import PhotoCarousel from 'marco/components/photo-carousel';
|
||||||
|
|
||||||
|
module('Integration | Component | photo-carousel', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders gracefully with no photos', async function (assert) {
|
||||||
|
this.photos = [];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PhotoCarousel @photos={{this.photos}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-carousel')
|
||||||
|
.doesNotExist('it does not render the wrapper when there are no photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders a single photo without navigation chevrons', async function (assert) {
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
url: 'photo1.jpg',
|
||||||
|
thumbUrl: 'thumb1.jpg',
|
||||||
|
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||||
|
ratio: 1.5,
|
||||||
|
isLandscape: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div class="test-container">
|
||||||
|
<PhotoCarousel @photos={{this.photos}} />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.photo-carousel').exists('it renders the wrapper');
|
||||||
|
assert
|
||||||
|
.dom('.carousel-slide:not(.carousel-placeholder)')
|
||||||
|
.exists({ count: 1 }, 'it renders one real photo slide');
|
||||||
|
assert
|
||||||
|
.dom('.carousel-placeholder')
|
||||||
|
.exists({ count: 1 }, 'it renders one placeholder');
|
||||||
|
assert
|
||||||
|
.dom('img.place-header-photo')
|
||||||
|
.hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly');
|
||||||
|
|
||||||
|
// There should be no chevrons when there's only 1 photo
|
||||||
|
assert
|
||||||
|
.dom('.carousel-nav-btn')
|
||||||
|
.doesNotExist('it does not render chevrons for a single photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders multiple photos and shows chevrons', async function (assert) {
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
url: 'photo1.jpg',
|
||||||
|
thumbUrl: 'thumb1.jpg',
|
||||||
|
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||||
|
ratio: 1.5,
|
||||||
|
isLandscape: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'photo2.jpg',
|
||||||
|
thumbUrl: 'thumb2.jpg',
|
||||||
|
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||||
|
ratio: 1.0,
|
||||||
|
isLandscape: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'photo3.jpg',
|
||||||
|
thumbUrl: 'thumb3.jpg',
|
||||||
|
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
|
||||||
|
ratio: 0.8,
|
||||||
|
isLandscape: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div class="test-container">
|
||||||
|
<PhotoCarousel @photos={{this.photos}} />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
assert.dom('.carousel-slide').exists({ count: 3 }, 'it renders all slides');
|
||||||
|
assert
|
||||||
|
.dom('.carousel-nav-btn')
|
||||||
|
.exists({ count: 2 }, 'it renders both chevrons');
|
||||||
|
|
||||||
|
// Initially, it shouldn't be able to scroll left
|
||||||
|
assert
|
||||||
|
.dom('.carousel-nav-btn.prev')
|
||||||
|
.hasClass('disabled', 'the prev button is disabled initially');
|
||||||
|
assert
|
||||||
|
.dom('.carousel-nav-btn.next')
|
||||||
|
.doesNotHaveClass('disabled', 'the next button is enabled initially');
|
||||||
|
|
||||||
|
// We can't perfectly test native scroll behavior easily in JSDOM/QUnit without mocking the DOM elements' scroll properties,
|
||||||
|
// but we can test that clicking the next button triggers the scrolling method.
|
||||||
|
// However, since we mock scrollLeft in the component logic implicitly via template action, let's at least ensure clicking doesn't throw.
|
||||||
|
await click('.carousel-nav-btn.next');
|
||||||
|
|
||||||
|
assert.ok(true, 'clicking next button does not throw');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PhotoGallery from 'marco/components/photo-gallery';
|
||||||
|
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
class MockBlossomService extends Service {
|
||||||
|
async delete() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockToastService extends Service {
|
||||||
|
show() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Integration | Component | photo-gallery', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupNostrMocks(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:blossom', MockBlossomService);
|
||||||
|
this.owner.register('service:toast', MockToastService);
|
||||||
|
|
||||||
|
this.blossom = this.owner.lookup('service:blossom');
|
||||||
|
this.nostrAuth = this.owner.lookup('service:nostrAuth');
|
||||||
|
this.nostrData = this.owner.lookup('service:nostrData');
|
||||||
|
this.nostrRelay = this.owner.lookup('service:nostrRelay');
|
||||||
|
this.toast = this.owner.lookup('service:toast');
|
||||||
|
this.settings = this.owner.lookup('service:settings');
|
||||||
|
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
eventId: 'event1',
|
||||||
|
pubkey: 'userA',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
thumbUrl:
|
||||||
|
'https://example.com/b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'event2',
|
||||||
|
pubkey: 'userB',
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'photo2.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
localStorage.removeItem('marco:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not show delete button if user is not creator', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.doesNotExist('Delete button is hidden for non-creator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
assert.dom('.dropdown-popover').exists('Dropdown opened');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-item.text-danger')
|
||||||
|
.exists('Delete button is visible for creator when setting is enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles cancellation of deletion', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(false);
|
||||||
|
const blossomSpy = sinon.spy(this.blossom, 'delete');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
// Override the mock's getter just for this test
|
||||||
|
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
signEvent: async (e) => ({
|
||||||
|
...e,
|
||||||
|
id: 'signed-id',
|
||||||
|
sig: 'sig',
|
||||||
|
pubkey: 'userA',
|
||||||
|
}),
|
||||||
|
getPublicKey: async () => 'userA',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||||
|
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||||
|
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||||
|
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
|
|
||||||
|
// Check blossom deletions
|
||||||
|
assert.ok(
|
||||||
|
blossomStub.calledTwice,
|
||||||
|
'blossom.delete was called twice (main + thumb)'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.firstCall.args[0],
|
||||||
|
'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for main image'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
blossomStub.secondCall.args[0],
|
||||||
|
'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
'extracted correct hash for thumb image'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Nostr kind 5
|
||||||
|
assert.ok(publishStub.calledOnce, 'nostrRelay.publish was called');
|
||||||
|
const publishedEvent = publishStub.firstCall.args[1];
|
||||||
|
assert.strictEqual(publishedEvent.kind, 5, 'published event is kind 5');
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[0],
|
||||||
|
['e', 'event1'],
|
||||||
|
'event tags reference the deleted photo'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
publishedEvent.tags[1],
|
||||||
|
['i', 'osm:node:12345'],
|
||||||
|
'event tags include the place identifier'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check store update
|
||||||
|
assert.ok(storeStub.calledOnce, 'nostrData.store.add was called');
|
||||||
|
assert.strictEqual(
|
||||||
|
storeStub.firstCall.args[0].kind,
|
||||||
|
5,
|
||||||
|
'added kind 5 event to local store'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check UX
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Photo deleted successfully'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
assert.ok(closed, 'gallery was closed after deletion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it copies event id to clipboard', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = 'userA';
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
const clipboardStub = sinon
|
||||||
|
.stub(navigator.clipboard, 'writeText')
|
||||||
|
.resolves();
|
||||||
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
|
||||||
|
// Find the copy button (it should be the first one)
|
||||||
|
const items = document.querySelectorAll('.dropdown-item');
|
||||||
|
let copyBtn;
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.textContent.includes('Copy Photo Event ID')) {
|
||||||
|
copyBtn = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await click(copyBtn);
|
||||||
|
|
||||||
|
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||||
|
assert.ok(
|
||||||
|
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||||
|
'success toast was shown'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation changes photos', async function (assert) {
|
||||||
|
this.photos = [
|
||||||
|
{ eventId: 'event1', url: 'photo1.jpg' },
|
||||||
|
{ eventId: 'event2', url: 'photo2.jpg' },
|
||||||
|
{ eventId: 'event3', url: 'photo3.jpg' },
|
||||||
|
];
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event1');
|
||||||
|
|
||||||
|
// Right Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
|
||||||
|
// Right Arrow again
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event3');
|
||||||
|
|
||||||
|
// Left Arrow
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escape key closes gallery', async function (assert) {
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||||
|
assert.ok(closed, 'gallery was closed on escape key');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -255,4 +255,83 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
assert.dom('.actions button').hasText('Save');
|
assert.dom('.actions button').hasText('Save');
|
||||||
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it aggregates phone and mobile tags without duplicates', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Phone Shop',
|
||||||
|
osmTags: {
|
||||||
|
phone: '+1-234-567-8900',
|
||||||
|
'contact:phone': '+1-234-567-8900; +1 000 000 0000',
|
||||||
|
mobile: '+1 987 654 3210',
|
||||||
|
'contact:mobile': '+1 987 654 3210',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Use specific selector for the phone block since there's no cuisine or opening_hours
|
||||||
|
const metaInfos = Array.from(
|
||||||
|
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||||
|
);
|
||||||
|
const phoneBlock = metaInfos.find((el) => {
|
||||||
|
const iconSpan = el.querySelector('span.icon[title="Phone"]');
|
||||||
|
return !!iconSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(phoneBlock, 'Phone block is rendered');
|
||||||
|
|
||||||
|
const links = phoneBlock.querySelectorAll('a[href^="tel:"]');
|
||||||
|
assert.strictEqual(
|
||||||
|
links.length,
|
||||||
|
3,
|
||||||
|
'Rendered exactly 3 unique phone links'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(links[0].getAttribute('href'), 'tel:+12345678900');
|
||||||
|
assert.strictEqual(links[1].getAttribute('href'), 'tel:+10000000000');
|
||||||
|
assert.strictEqual(links[2].getAttribute('href'), 'tel:+19876543210');
|
||||||
|
|
||||||
|
assert.dom(links[0]).hasText('+1-234-567-8900');
|
||||||
|
assert.dom(links[1]).hasText('+1 000 000 0000');
|
||||||
|
assert.dom(links[2]).hasText('+1 987 654 3210');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it formats whatsapp tags into wa.me links', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Chat Shop',
|
||||||
|
osmTags: {
|
||||||
|
'contact:whatsapp': '+1 234-567 8900',
|
||||||
|
whatsapp: '+44 987 654 321', // Also tests multiple values
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const metaInfos = Array.from(
|
||||||
|
this.element.querySelectorAll('.meta-info .content-with-icon')
|
||||||
|
);
|
||||||
|
const whatsappBlock = metaInfos.find((el) => {
|
||||||
|
const iconSpan = el.querySelector('span.icon[title="WhatsApp"]');
|
||||||
|
return !!iconSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(whatsappBlock, 'WhatsApp block is rendered');
|
||||||
|
|
||||||
|
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
|
||||||
|
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
|
||||||
|
|
||||||
|
// Verify it stripped the dashes and spaces for the wa.me URL
|
||||||
|
assert.strictEqual(
|
||||||
|
links[0].getAttribute('href'),
|
||||||
|
'https://wa.me/+44987654321'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
links[1].getAttribute('href'),
|
||||||
|
'https://wa.me/+12345678900'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it kept the dashes and spaces for the visible text
|
||||||
|
assert.dom(links[0]).hasText('+44 987 654 321');
|
||||||
|
assert.dom(links[1]).hasText('+1 234-567 8900');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PlacePhotoUpload from 'marco/components/place-photo-upload';
|
||||||
|
|
||||||
|
module('Integration | Component | place-photo-upload', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class MockNostrAuthService extends Service {
|
||||||
|
get isConnected() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectFile(element, file) {
|
||||||
|
const input = element.querySelector('#photo-upload-input');
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: [file],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(input, 'change');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('it shows tag suggestions when they exist after upload selection', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').exists();
|
||||||
|
assert.dom('.photo-tag-chip').exists();
|
||||||
|
assert.dom('.photo-tag-chip').includesText('Food');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it only allows one selected tag at a time', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
const chips = this.element.querySelectorAll('.photo-tag-chip');
|
||||||
|
assert.ok(chips.length > 1, 'multiple tag chips are rendered');
|
||||||
|
|
||||||
|
await click(chips[0]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[0]).hasClass('is-selected');
|
||||||
|
|
||||||
|
await click(chips[1]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[1]).hasClass('is-selected');
|
||||||
|
assert.dom(chips[0]).doesNotHaveClass('is-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it hides tag suggestions when no tags are suggested', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Office Beta',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { office: 'lawyer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
|
import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers';
|
||||||
import SearchBox from 'marco/components/search-box';
|
import SearchBox from 'marco/components/search-box';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
|
||||||
@@ -208,22 +208,301 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Type "Resta" to trigger "Restaurants" category match
|
// Type "Resta" to trigger "Restaurants" category match
|
||||||
|
await focus('.search-input');
|
||||||
await fillIn('.search-input', 'Resta');
|
await fillIn('.search-input', 'Resta');
|
||||||
|
|
||||||
// Wait for debounce (300ms) + execution
|
await waitFor('.search-result-item');
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
await delay(400);
|
|
||||||
|
|
||||||
// The first result should be the category match
|
const resultItems = Array.from(
|
||||||
assert.dom('.search-result-item').exists({ count: 1 });
|
this.element.querySelectorAll('.search-result-item')
|
||||||
assert.dom('.result-title').hasText('Restaurants');
|
);
|
||||||
|
const categoryResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('Restaurants')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryResult, 'Restaurants category result is shown');
|
||||||
|
|
||||||
// Click the result
|
// Click the result
|
||||||
await click('.search-result-item');
|
await click(categoryResult);
|
||||||
|
|
||||||
// Assert transition with lat/lon from map center
|
// Assert transition with lat/lon from map center
|
||||||
assert.verifySteps([
|
assert.verifySteps([
|
||||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'coffee') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'Duplicate to be removed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Other Coffee',
|
||||||
|
osmId: '888',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'A different coffee shop',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "coffee" to trigger matches in Category, Saved, and Photon
|
||||||
|
await fillIn('.search-input', 'coffee');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should be exactly 3 items:
|
||||||
|
// 1. Category (Coffee)
|
||||||
|
// 2. Saved (Awesome Coffee)
|
||||||
|
// 3. Photon (Other Coffee)
|
||||||
|
// (The Photon duplicate of "Awesome Coffee" is removed)
|
||||||
|
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
|
||||||
|
|
||||||
|
// 1. Category
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Coffee'),
|
||||||
|
'First item is the category match'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[0].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-search', 'Category uses search icon');
|
||||||
|
|
||||||
|
// 2. Saved Place
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Awesome Coffee'),
|
||||||
|
'Second item is the saved place match'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Saved place'),
|
||||||
|
'Saved place has correct description text'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[1].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
|
||||||
|
|
||||||
|
// 3. Photon Match
|
||||||
|
assert.ok(
|
||||||
|
resultItems[2].textContent.includes('Other Coffee'),
|
||||||
|
'Third item is the unique photon result'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the Saved Place
|
||||||
|
await click(resultItems[1]);
|
||||||
|
|
||||||
|
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it requires 3 or more characters to match saved places', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'aw' || query === 'awe') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Aww Some Place',
|
||||||
|
osmId: '111',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "aw" (2 characters)
|
||||||
|
await fillIn('.search-input', 'aw');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
let resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only show Photon match since 'aw' is < 3 characters
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
1,
|
||||||
|
'Renders exactly 1 item for 2 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Aww Some Place'),
|
||||||
|
'Shows photon match'
|
||||||
|
);
|
||||||
|
assert.notOk(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is NOT shown for 2 char query'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "awe" (3 characters)
|
||||||
|
await fillIn('.search-input', 'awe');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should now show Saved Place and Photon match
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
2,
|
||||||
|
'Renders exactly 2 items for 3 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is now shown'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) =>
|
||||||
|
item.textContent.includes('Saved place (Favorites)')
|
||||||
|
),
|
||||||
|
'List names are appended to the description'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service (Custom Place)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
id: 'custom-1234',
|
||||||
|
title: 'My Custom Home',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
// Notice NO osmId or osmType
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type 3 chars to trigger saved place match
|
||||||
|
await fillIn('.search-input', 'cus');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure our custom place is rendered
|
||||||
|
const customResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('My Custom Home')
|
||||||
|
);
|
||||||
|
assert.ok(customResult, 'Custom place is rendered');
|
||||||
|
|
||||||
|
// Click it
|
||||||
|
await click(customResult);
|
||||||
|
|
||||||
|
// Verify it navigated using the internal ID, NOT a search query
|
||||||
|
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,6 +36,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
selectPlaceCalled = true;
|
selectPlaceCalled = true;
|
||||||
}
|
}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -76,6 +78,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
class MapUiStub extends Service {
|
class MapUiStub extends Service {
|
||||||
selectPlace() {}
|
selectPlace() {}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -110,6 +114,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
class MapUiStub extends Service {
|
class MapUiStub extends Service {
|
||||||
selectPlace() {}
|
selectPlace() {}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:osm', OsmStub);
|
this.owner.register('service:osm', OsmStub);
|
||||||
@@ -155,6 +161,8 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||||
}
|
}
|
||||||
stopSearch() {}
|
stopSearch() {}
|
||||||
|
showSidebar() {}
|
||||||
|
hideSidebar() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.owner.register('service:storage', StorageStub);
|
this.owner.register('service:storage', StorageStub);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ module('Unit | Service | osm-auth', function (hooks) {
|
|||||||
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
|
// Because restoreSession runs in the constructor, we might need to overwrite it after, but it's async.
|
||||||
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
|
// Let's just create it, let the original restoreSession fail or do nothing, and then we stub and re-call it.
|
||||||
|
|
||||||
|
service.oauthClient.ready = Promise.resolve();
|
||||||
service.oauthClient.isAuthorized = async () => true;
|
service.oauthClient.isAuthorized = async () => true;
|
||||||
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
|
window.localStorage.setItem('marco:osm_user_display_name', 'CachedName');
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ module('Unit | Service | osm-auth', function (hooks) {
|
|||||||
test('it fetches user info when logged in but no cached name', async function (assert) {
|
test('it fetches user info when logged in but no cached name', async function (assert) {
|
||||||
let service = this.owner.factoryFor('service:osm-auth').create();
|
let service = this.owner.factoryFor('service:osm-auth').create();
|
||||||
|
|
||||||
|
service.oauthClient.ready = Promise.resolve();
|
||||||
service.oauthClient.isAuthorized = async () => true;
|
service.oauthClient.isAuthorized = async () => true;
|
||||||
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
|
service.oauthClient.getTokens = async () => ({ accessToken: 'fake-token' });
|
||||||
// Ensure localStorage is empty for this key
|
// Ensure localStorage is empty for this key
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
|
||||||
|
|
||||||
|
module('Unit | Utility | nostr', function () {
|
||||||
|
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
|
||||||
|
assert.strictEqual(normalizeRelayUrl(null), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(''), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(' '), '');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('Relay.example.com'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('ws://Relay.example.com/'),
|
||||||
|
'ws://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('wss://relay.example.com///'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos includes event t tags on photo objects', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 123,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['t', 'food'],
|
||||||
|
['t', 'vibe'],
|
||||||
|
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.deepEqual(photos[0].tags, ['food', 'vibe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos sorts by created_at', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/late.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/early.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/early.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/late.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos skips imeta entries without urls', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [['imeta', 'dim 800x600']],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.deepEqual(photos, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos returns one photo per event imeta tag', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
|
||||||
|
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher';
|
||||||
|
import {
|
||||||
|
getSuggestedPhotoTags,
|
||||||
|
CATEGORY_TAGS,
|
||||||
|
} from 'marco/utils/photo-tag-suggestions';
|
||||||
|
|
||||||
|
module('Unit | Utility | photo-tag-suggestions', function () {
|
||||||
|
test('returns tags for all matching categories with de-duplication', function (assert) {
|
||||||
|
const place = { osmTags: { amenity: 'cafe' } };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(
|
||||||
|
place.osmTags,
|
||||||
|
POI_CATEGORIES
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
assert.deepEqual(result, CATEGORY_TAGS.restaurants);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns no tags when no category matches', function (assert) {
|
||||||
|
const place = { osmTags: { office: 'lawyer' } };
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import {
|
||||||
|
getMatchingPoiCategories,
|
||||||
|
getMatchingPoiCategoryIds,
|
||||||
|
} from 'marco/utils/poi-category-matcher';
|
||||||
|
|
||||||
|
module('Unit | Utility | poi-category-matcher', function () {
|
||||||
|
test('matches multiple categories from OSM tags', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports semicolon-separated values', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe;bar' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative regex clause fails if any value matches', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe', cuisine: 'coffee;irish' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.notOk(categoryIds.includes('restaurants'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presence clause matches when tag exists', function (assert) {
|
||||||
|
const tags = { historic: 'castle' };
|
||||||
|
const categories = getMatchingPoiCategories(tags, POI_CATEGORIES);
|
||||||
|
const categoryIds = categories.map((category) => category.id);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('things-to-do'));
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
ember(),
|
ember(),
|
||||||
|
|||||||
Reference in new issue
Block a user