Compare commits

...

111 commits

Author SHA1 Message Date
SkateSpots
9ce57bedf9 docs: remove external submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:18:58 +07:00
SkateSpots
bfc777c18b docs: make distribution Nostr-only, no external identities 2026-10-30 23:17:54 +07:00
SkateSpots
a2b732317e dist: point F-Droid recipe at splash-tracked source
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:11:15 +07:00
SkateSpots
5ebf741427 build: track Android splash source for clean checkouts 2026-10-30 23:09:23 +07:00
SkateSpots
4ae30ee8e6 dist: prepare unsigned F-Droid build output 2026-10-30 23:07:42 +07:00
SkateSpots
72e5f89b49 docs: add external store submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:04:13 +07:00
SkateSpots
d003748bb1 dist: use valid F-Droid category
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:02:17 +07:00
SkateSpots
6048eafc0d docs: record future distribution and external submission limits
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:59:06 +07:00
SkateSpots
562a7de7c0 docs: record Forgejo release helper
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:53:00 +07:00
SkateSpots
8b12001fa6 docs: document self-hosted F-Droid republish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:51:09 +07:00
SkateSpots
383ff8a5cf dist: add Forgejo release helper and F-Droid instructions
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:49:23 +07:00
SkateSpots
65914144d1 dist: document self-hosted Forgejo and F-Droid repo
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:43:47 +07:00
SkateSpots
a8780d5236 dist: self-hosted Forgejo release page and F-Droid metadata 2026-10-30 22:33:53 +07:00
SkateSpots
2dcf5a2aad docs: record 1.7.61 Cordn setup timeline
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:25:18 +07:00
SkateSpots
50c95d0ff7 release: 1.7.61 2026-10-30 22:21:24 +07:00
SkateSpots
4ff736734d ui(cordn): show setup progress through the join request 2026-10-30 22:18:11 +07:00
SkateSpots
fd1713f6b1 docs: record 1.7.60 Cordn and payment polish 2026-10-30 22:07:49 +07:00
SkateSpots
6c9b6fab02 release: 1.7.60 2026-10-30 22:03:46 +07:00
SkateSpots
0ae4c59630 feat(ux): cordn status line, payment readiness and image compression 2026-10-30 22:00:31 +07:00
SkateSpots
ab11b0d3ed docs: record 1.7.59 owner delete and mobile back 2026-10-30 21:28:21 +07:00
SkateSpots
0355c10dbc release: 1.7.59 2026-10-30 21:24:26 +07:00
SkateSpots
28d34e3fbe fix(ui): owner spot delete, mobile back and simplified account menu 2026-10-30 21:21:00 +07:00
SkateSpots
b0df599b52 docs: record 1.7.58 brand profile shortcut 2026-10-30 21:12:14 +07:00
SkateSpots
74bd140295 release: 1.7.58 2026-10-30 21:08:15 +07:00
SkateSpots
42f47304dd ui(header): brand lockup opens the user profile when signed in 2026-10-30 21:04:47 +07:00
SkateSpots
c00bffdf78 docs: record 1.7.57 avatar profile shortcut 2026-10-30 20:58:40 +07:00
SkateSpots
5026b63323 release: 1.7.57 2026-10-30 20:54:42 +07:00
SkateSpots
601393ece3 ui(header): make the user avatar open the profile 2026-10-30 20:51:21 +07:00
SkateSpots
ee6db357c2 docs: record 1.7.56 navigation simplification 2026-10-30 20:33:42 +07:00
SkateSpots
cdf4a63809 release: 1.7.56 2026-10-30 20:29:12 +07:00
SkateSpots
23929653d3 ui(nav): simplify mobile bar to map, three tabs and more 2026-10-30 20:25:52 +07:00
SkateSpots
344b41afc8 docs: record 1.7.55 operator dashboard and TLS hardening 2026-10-30 18:47:43 +07:00
SkateSpots
52a84479d4 release: 1.7.55 2026-10-30 18:43:52 +07:00
SkateSpots
f71f5eeef8 feat(ops): signed read-only relay status dashboard + HSTS/CAA hardening 2026-10-30 18:40:32 +07:00
SkateSpots
1f27311903 docs: record 1.7.54 Blossom quotas 2026-10-30 18:16:41 +07:00
SkateSpots
465683cf71 release: 1.7.54 2026-10-30 18:12:56 +07:00
SkateSpots
a4f72a3d05 feat(media): image-only per-npub Blossom quotas and upload proxy 2026-10-30 18:09:44 +07:00
SkateSpots
c4a1671f81 fix(ops): avoid false Cordn announce alerts from SIGPIPE 2026-10-30 17:44:59 +07:00
SkateSpots
20b2b0d676 docs: record 1.7.53 Cordn reliability and relay limits 2026-10-30 17:43:06 +07:00
SkateSpots
95ff486038 release: 1.7.53 2026-10-30 17:39:13 +07:00
SkateSpots
7791cbe9fa fix(cordn): back off on relay pressure and surface relay health 2026-10-30 17:35:59 +07:00
SkateSpots
26a8c5eb31 docs: record 1.7.52 support page redesign 2026-10-30 17:20:17 +07:00
SkateSpots
69a86493e8 release: 1.7.52 2026-10-30 17:16:40 +07:00
SkateSpots
f2787d79cf ui(support): compact tabbed support page with friendlier /support alias 2026-10-30 17:13:25 +07:00
SkateSpots
a1b069da13 docs: record 1.7.51 profile payment polish 2026-10-30 13:43:04 +07:00
SkateSpots
c65e6effb5 release: 1.7.51 2026-10-30 13:38:33 +07:00
SkateSpots
b39c2ec386 fix(payments): round out profile CLINK and Lightning methods 2026-10-30 13:35:18 +07:00
SkateSpots
22eb46c62b docs: record 1.7.50 performance and navigation release 2026-10-30 13:19:09 +07:00
SkateSpots
65a7f0bdbb release: 1.7.50 2026-10-30 13:15:18 +07:00
SkateSpots
f95adeecf1 perf: faster shell, lazy wallet stack, serialized Amber and navigation polish 2026-10-30 13:12:06 +07:00
SkateSpots
f8a1054894 docs: record 1.7.49 Cordn setup fix 2026-10-30 10:31:40 +07:00
SkateSpots
7958ca59bc release: 1.7.49 2026-10-30 10:28:12 +07:00
SkateSpots
68b77d6c14 fix(amber): serialize Cordn setup and explain the two approvals 2026-10-30 10:25:04 +07:00
SkateSpots
8a347b4435 docs: record 1.7.48 Amber cancellation fix 2026-10-30 10:14:58 +07:00
SkateSpots
3d905af6ec release: 1.7.48 2026-10-30 10:11:29 +07:00
SkateSpots
1c361b8df5 fix(amber): stop decrypt loops after navigation 2026-10-30 10:08:19 +07:00
SkateSpots
20ff45089f docs: record 1.7.47 faster splash 2026-10-30 09:37:17 +07:00
SkateSpots
e22e2ec85f release: 1.7.47 2026-10-30 09:33:49 +07:00
SkateSpots
dbc78a1760 feat: faster splash with static logo and aperture exit 2026-10-30 09:30:50 +07:00
SkateSpots
0175645f3a docs: record 1.7.46 splash transition 2026-10-30 09:09:07 +07:00
SkateSpots
008fb81f73 release: 1.7.46 2026-10-30 09:06:20 +07:00
SkateSpots
9838aa6d72 feat: smoother native-to-splash handoff and app reveal 2026-10-30 09:03:13 +07:00
SkateSpots
a9dffbf09d docs: record 1.7.44/1.7.45 native boot fixes 2026-10-30 01:26:11 +07:00
SkateSpots
afd5a09acd release: 1.7.45 2026-10-30 01:22:38 +07:00
SkateSpots
e234253870 fix: eager-load the map and harden native boot cleanup 2026-10-30 01:19:19 +07:00
SkateSpots
c66aeb526f release: 1.7.44 2026-10-30 01:12:14 +07:00
SkateSpots
267e93abc7 fix: never abort native boot on service-worker cleanup 2026-10-30 01:08:58 +07:00
SkateSpots
9dd2231332 docs: record 1.7.43 boot-loop breaker 2026-10-30 01:02:45 +07:00
SkateSpots
1a88f8637d release: 1.7.43 2026-10-30 00:59:24 +07:00
SkateSpots
b95371a123 fix: break native boot reload loops before they trap the app 2026-10-30 00:56:19 +07:00
SkateSpots
b76891798e docs: record 1.7.41/1.7.42 native boot recovery 2026-10-30 00:46:36 +07:00
SkateSpots
7dc8c2c08b release: 1.7.42 2026-10-30 00:43:31 +07:00
SkateSpots
822cca23d7 fix: make native boot background black and bust stale boot guard cache 2026-10-30 00:40:18 +07:00
SkateSpots
c36bf2f029 release: 1.7.41 2026-10-30 00:34:46 +07:00
SkateSpots
443c7122d8 fix: version and bypass app-shell worker during native boot 2026-10-30 00:31:40 +07:00
SkateSpots
2d9758593a docs: record 1.7.39/1.7.40 audit and native boot fix 2026-10-30 00:28:15 +07:00
SkateSpots
44be542366 release: 1.7.40 2026-10-30 00:23:46 +07:00
SkateSpots
7719be12d9 fix: clear stale native app-shell service workers before boot 2026-10-30 00:20:32 +07:00
SkateSpots
6aba9b356f release: 1.7.39 2026-10-30 00:13:39 +07:00
SkateSpots
7a928dae6d feat: profile Lab services strip and dependency-light XLSX export 2026-10-30 00:10:29 +07:00
SkateSpots
bf47d43674 docs: record 1.7.38 audit fixes 2026-10-29 23:21:27 +07:00
SkateSpots
41f420ae55 release: 1.7.38 2026-10-29 23:17:30 +07:00
SkateSpots
262eb32e7c chore: audit fixes, designer stats and form FAB handling 2026-10-29 23:14:12 +07:00
SkateSpots
dddfe51f86 docs: record 1.7.37 Cordn empty-response fix 2026-10-29 23:00:28 +07:00
SkateSpots
b94ac3d409 release: 1.7.37 2026-10-29 22:56:49 +07:00
SkateSpots
eaf345e2ec fix: tolerate empty Cordn coordinator tool responses 2026-10-29 22:53:36 +07:00
SkateSpots
96503b672c docs: record 1.7.36 Transport Lab craft 2026-10-29 22:42:07 +07:00
SkateSpots
b0f6dd9f77 release: 1.7.36 2026-10-29 22:37:47 +07:00
SkateSpots
6746353670 feat: add Transport Lab craft with self-attested fleet profiles 2026-10-29 22:34:28 +07:00
SkateSpots
096f2155fe docs: record 1.7.34/1.7.35 stat fit and map footer 2026-10-29 21:43:46 +07:00
SkateSpots
5d40a67a8a release: 1.7.35 2026-10-29 21:39:31 +07:00
SkateSpots
569ad9b01c fix: replace floating map links pill with slim footer and fit follower stats 2026-10-29 21:35:45 +07:00
SkateSpots
d3d82f7d46 release: 1.7.34 2026-10-29 21:28:56 +07:00
SkateSpots
4a4f398df0 fix: fit follower stats and add grantmaker transparency 2026-10-29 21:25:08 +07:00
SkateSpots
957033e678 docs: record sponsorship opt-in and grantmaker form 2026-10-29 21:09:50 +07:00
SkateSpots
7f29a822ab release: 1.7.33 2026-10-29 21:01:36 +07:00
SkateSpots
3f35a6f3a4 feat: sponsorship opt-in and Bitcoin grantmaker inquiry 2026-10-29 20:57:43 +07:00
SkateSpots
8a6968735c docs: record 1.7.32 sponsor-ready profiles 2026-10-29 20:32:51 +07:00
SkateSpots
d921a6c090 release: 1.7.32 2026-10-29 20:24:40 +07:00
SkateSpots
bc1987f78c feat: sponsor-ready profiles with follow, events and audience stats 2026-10-29 20:20:49 +07:00
SkateSpots
9c22bd78f8 docs: record 1.7.31 Cordn UX, outbox and splash 2026-10-29 19:31:40 +07:00
SkateSpots
4483f82fe4 release: 1.7.31 2026-10-29 19:24:59 +07:00
SkateSpots
a2ab5750ff feat: Cordn inbox, outbox, room controls and animated splash 2026-10-29 19:18:48 +07:00
SkateSpots
d07f8a696d docs: record 1.7.30 community hardening and backups 2026-10-29 16:39:37 +07:00
SkateSpots
bde3d2484f release: 1.7.30 2026-10-29 16:32:56 +07:00
SkateSpots
c75ddea8e2 fix: harden community joins, approvals and state backups 2026-10-29 16:29:26 +07:00
SkateSpots
dcb1a3e674 docs: record 1.7.29 community polish and bot deploy tooling 2026-10-29 13:51:33 +07:00
SkateSpots
e02f2b4699 release: 1.7.29 2026-10-29 13:47:45 +07:00
SkateSpots
cded5eddb4 chore: community request feedback and host deploy tooling 2026-10-29 13:44:12 +07:00
SkateSpots
f94abcf33e chore: add community bot deploy/health tooling and Cordn web caveat 2026-10-29 13:42:32 +07:00
SkateSpots
5536eee2ff docs: record 1.7.28 community join fixes 2026-10-29 13:36:31 +07:00
230 changed files with 9289 additions and 1101 deletions

3
.gitignore vendored
View file

@ -52,7 +52,8 @@ stats.json
# Local/generated assets & incidental lockfiles (do not commit)
/assets/
/resources/
/resources/*
!/resources/splash.png
/bun.lock
cvm-server/bun.lock
push-service/bun.lock

383
AGENTS.md
View file

@ -659,6 +659,10 @@ session with **no other memory of this project**. So:
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
**Form-page visibility (2026-10-29):** the assistant is deliberately hidden on
`/become/*`, `/create*`, `/edit*`, `/supporters` and `/onboarding` — the
floating bubble covered form inputs/chips (especially on mobile). It remains
available everywhere else.
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
pulses forever — the `animate-ping` halo shows **only when an unread tip
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
@ -906,6 +910,16 @@ session with **no other memory of this project**. So:
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
aren't hard-coded**, so Marketplace/Mints (and any future section) are always
reachable on mobile — don't re-hardcode only a subset.
- **Transport / Driver Lab craft (2026-10-29):** kind `35016`
(`skatetransport`) is a data-driven `BECOME_ROLES.transport` craft over
`CraftProfileForm`. Vehicles (`car/taxi/van/moto/helicopter/plane/boat/other`),
services (`people/goods/gear/airport/venue/tours/logistics/other`),
capabilities, capacity and coverage are `t` tags; extras are `capacity`,
`cargo`, `coverage`, `availability`, `base`, `accessibility`. `LabPage` has a
Transport tab + filters, `CoachDetailPage` renders the shared trust/vouch/
review blocks and a prominent **self-attested** notice. No licence, insurance,
roadworthiness or operator permit is verified — never label it verified.
`useGrowthStats.totalTransport` / `/stats` and Supporters impact include it.
- **DIY resources (`/diy`, 2026-10-05):** a hub for making your own
boards/ramps/concrete. Content is a seed list in **`src/lib/diyGuides.ts`**
(`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` +
@ -1174,6 +1188,17 @@ session with **no other memory of this project**. So:
(`NIP19Page`): the app badge in the header, user badges in a "Badges" card.
⚠️ `NostrMetadata` has no `badges` field, so it's read via a cast
(`metadata as … & Record<string, unknown>`), like `sponsors`/`cordn` before it.
- **Sponsor-ready profile (2026-10-29):** `NIP19Page` now has Follow/Unfollow,
Followers/Following, **Events hosted** (`useUserEvents`), a six-cell stats
strip and a neutral **For sponsors & collabs** card (followers/events/spots/
zaps, prefilled collab DM, Rider Card link). The card only appears when the
profile has actual skate activity. NIP-05 has a loading/verifying state.
`useFollowing` invalidates follower counts on follow/unfollow. Sponsorship
is an **explicit opt-in** (`sponsorships_open` + optional
`sponsorships_note` in kind-0), never inferred from activity; the neutral card
is shown only when the profile has skate activity or opted in. `/supporters`
has a Bitcoin-grantmaker inquiry form that composes a private DM to the owner
(no storage) and a humorous KYC disclosure.
- **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate
spot" report used to only land in the admin queue (invisible effect). It now
**shows on the spot page to everyone** ("Reported as not a skate spot by N —
@ -1527,9 +1552,20 @@ session with **no other memory of this project**. So:
Owner/`crew.pubkey` = the bot `f978…f0`, admins = `ADMIN_PUBKEYS` + bot. The
page passes `canCreate={false}`: everyone (admins included) uses the normal
"Ask to join" flow; the bot auto-approves. After joining, admins still get the
requests panel as a fallback. Links: `/#/community` (app/QR) and the Advanced
`https://cordn.net/chat/<cordn1…>` for Staircase/Cordn clients. Entry points:
nav catalog/burger + About feedback card.
requests panel as a fallback. Links: `/community`, **`/c`** and **`/cordn`** all redirect into
`/#/community` (the old Cordn-web shortcuts were retired 2026-10-29 because
Cordn web stalls after NIP-42). The raw `cordn1…` reference is still
copyable from Advanced for Staircase/Cordn clients; the external open link
was removed.
Entry points: nav catalog/burger + About feedback card.
- **Join UX (2026-10-29):** community-specific approval copy names the bot or
admins, an admin pending-request banner links to the room's request panel,
and waiting users get **Request again** plus the second-device hint (use
**Link device** for a direct handoff, or ask to join again with the new
device; the earlier “already in the group” error was a stale request for the
same key package, not a same-account limitation).
If the bot is missing from the group, the panel warns that an admin must
review requests.
- **Bot:** `scripts/community-bot.ts`, built to CJS with
`scripts/build-community-bot.sh` (Node 20 needs the `ws` global shim), run by
`scripts/systemd/skatespots-community.service` on the relay box:
@ -1539,10 +1575,29 @@ session with **no other memory of this project**. So:
with `COMMUNITY_CREATE=1`; **leave `COMMUNITY_CREATE=0` afterward** — a lost
state plus create=1 would fork the room. It polls every 15s, auto-approves
up to 10 requests/cycle, skips its own request, and backs off failed
approvals for 5 minutes.
approvals for 5 minutes. **2026-10-29 fixes:** it prefers the request's
`kp_ref` (fallback pubkey), retires handled/stale requests through
`join_request_take_many`'s `consumed` list (the old bot left approved
requests in the queue and retried "already in the group" forever), logs
pending counts, and stops auto-approval at **250 members** /
**5 devices per pubkey**, and **30 approvals/hour**
(`COMMUNITY_MAX_MEMBERS`, `COMMUNITY_MAX_DEVICES_PER_PUBKEY`,
`COMMUNITY_MAX_APPROVALS_PER_HOUR`). The bot must be rebuilt with
`scripts/build-community-bot.sh` and copied to
`/opt/skatespots-community/community-bot.cjs` when this file changes —
**`scripts/deploy-community-bot.sh`** does the build, remote backup,
install, restart and local state-backup refresh in one step (host alias
`skatespots-cvm`; `--no-build` reuses `build/community-bot.cjs`).
- **Secrets/backup:** bot key in `~/.config/skatespots/community.env` (600)
and the VPS env; state copy in
`~/.config/skatespots/community-state.json` (600). Never commit/print either.
**Daily backup (2026-10-29):** `scripts/backup-community-state.sh` pulls the
live state, keeps 30 daily snapshots in `~/.backups/community`, and pushes
the directory to restic (tag `community-state`) when
`~/.config/skatespots/restic.env` exists. Installed by
`scripts/install-community-backup-timer.sh` as user timer
`skatespots-community-backup.timer` (04:17, Persistent). `ops-health.sh`
reports both the bot service and the timer.
- **Copy:** `communityUi.*` (en/es + all 24; 100%); bot rows are labeled
"Community bot" by pubkey, no profile event required.
- **Cordn native devices/members/benchmark (2026-10-28):** a new device can
@ -1562,6 +1617,16 @@ session with **no other memory of this project**. So:
tombstone, not a relay hard-delete; automatic read receipts are silent-signer
only (Amber would prompt). Live benchmark (`scripts/cordn-benchmark.ts`, 30
msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message).
- **Cordn room UX + resilience (2026-10-29):** `/messages` has a **Private
rooms** section with Community + every crew MLS room, Open/Set up state, and
local unread dots (`hooks/useCordnRoomRead.ts` + `lib/cordnRoomRead.ts`; only
rooms actually opened record `latestAt/seenAt`, so a dot is never invented).
The panel has per-room mute (`useDmMute` key `cordn:<gid>`), in-room search,
Chat-vs-Private guidance, coordinator **last checked**, and confirmations for
approve/remove. Failed sends persist in a per-account/room device-local outbox
(`lib/cordnOutbox.ts`) and auto-retry up to 3 times when online, then stay for
explicit Retry/Discard. Background unread for rooms never opened still needs a
global poller; there is deliberately no background decrypt.
- **Cordn join-request push (2026-10-28):** `scripts/cordn-join-watcher.ts`
runs every minute (user timer) and polls the coordinator for known crew gids.
New requests become (a) a **NIP-17 DM** to the founder/admins (push service
@ -1573,11 +1638,13 @@ session with **no other memory of this project**. So:
**custom coordinator** are skipped (only the SkateSpots embedded coordinator is
queried); approved requests leave the coordinator but the bell card remains
until dismissed.
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(DIY/Newsletter/License/Privacy + "not sponsored by OpenSats") as an overlay
inside the map container: a **slim full-width bar on desktop** and a **tiny
collapsed "Links" chip on mobile** (expands to a compact scrollable row).
- **Map site-links strip (2026-10-05; mobile bar 2026-10-29):** the desktop footer
is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces
the same links (DIY/Newsletter/License/Privacy + "not sponsored by OpenSats")
as an overlay inside the map container. Desktop is a slim full-width bar;
mobile is now a **slim always-visible strip above the bottom nav** (the old
floating “Links” chip in the centre was removed — it collided visually with
the Events/Crews nav items and its popover looked broken).
**Always visible (2026-10-28):** the old dismissible `useMapFooterHidden`
behavior/setting was removed — it was easy to hide the only footer on the map
forever and then report it missing. The map's OpenStreetMap attribution moved
@ -2159,9 +2226,9 @@ session with **no other memory of this project**. So:
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
retries, then one full reload per session, so a chunk-load failure self-heals
instead of showing the error boundary.
- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**,
**101 tests**, build ok. The gate also type-checks the CVM server
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.5.4**.
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
**156 tests**, build ok. The gate also type-checks the CVM server
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.61**.
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
@ -2311,6 +2378,12 @@ The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped o
- **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`.
- **Signing**: secrets live in the gitignored `keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`/home/sovereignsk8/skatespots-release.jks`** (alias `skatespots`). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`.
- `android/` is **generated and gitignored**; recreate with `npm run android:init`.
- **Launch splash:** `resources/splash.png` is the orange brand wheel. Capacitor's
generated drawables used a stock blue placeholder, so `scripts/make-android-splash.mjs`
(sharp) regenerates all portrait/landscape densities from the tracked source;
`android:init`/`android:sync`/`android:build` all run it. The web splash
(`components/SplashScreen.tsx`) uses `public/images/icon-1024.png` with a roll-in,
rolling spin and CSS sparks; `prefers-reduced-motion` falls back to a static mark.
- Preflight: `cd android && ./gradlew :app:signingReport` confirms the signing config resolves.
### Version bump + release notes
@ -2342,6 +2415,75 @@ Rules:
- Any time the deploy output isn't verified by the smoke test, run
`node scripts/smoke-test.mjs` manually before shipping.
### Distribution without GitHub
> **Owner decision (2026-10-30): no external identities.** Distribution must
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
> Codeberg, GitHub, email or F-Droid-main accounts. The local Forgejo admin
> account and the self-hosted F-Droid repo key are infrastructure credentials,
> not third-party identities. F-Droid main and IzzyOnDroid are therefore
> **out of scope**.
- **Self-hosted Forgejo:** `https://git.skate-spots.org/skatespots/SkateSpots`
(public source, registration disabled, admin credentials only on the relay
host). It runs on `185.18.221.2` behind Caddy; do not commit its credentials.
- **Latest release (Obtanium HTML source):**
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`.
The page exposes one signed `.apk`; Obtanium may need **Override Source →
HTML** because the host is self-hosted, not Codeberg.
- **Static download page:** `public/download/index.html` → `https://skate-spots.org/download/`.
- **Forgejo release helper:** `scripts/publish-forgejo.sh` (uses `FORGEJO_TOKEN`
or `FORGEJO_PASSWORD`; `scripts/release.sh` calls it automatically when either
is set). It uploads the APK and checksum, but does not push git history.
- **Self-hosted F-Droid repo:** `https://fdroid.skate-spots.org/repo`
(`org.skatespots.app`; currently 1.7.61). It is a **third-party repo**, not
F-Droid main. Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`.
The developer APK signature is unchanged; the repo key only signs indexes.
A backup of the repo keystore/config is in
`~/.config/skatespots/fdroid-repo/` (mode 600).
- **F-Droid main/IzzyOnDroid:** **out of scope by owner decision.** Both require
external GitLab/Codeberg identities and review; do not submit, create accounts,
or present them as live. The self-hosted F-Droid repo above is the supported
F-Droid-family channel.
- **No GitHub required.** Keep Zapstore, Forgejo, the self-hosted F-Droid repo,
and the static page in sync for each release.
### Future release / distribution checklist
1. Bump `package.json`; update the top of `release-notes.txt`; run
`scripts/release.sh patch --yes --deploy`.
2. Set `FORGEJO_TOKEN` (or `FORGEJO_PASSWORD`) to upload the APK/checksum to the
latest Forgejo release; the helper is `scripts/publish-forgejo.sh`.
3. **Push source/tags to Forgejo.** The local machine's DNS for
`git.skate-spots.org` is intermittent, so the reliable path is: create a git
bundle locally, `scp` it to the relay, clone there, and push to
`http://127.0.0.1:3000/skatespots/SkateSpots.git` with the host admin
credentials. Do not put the password in a committed remote URL.
4. Add the APK to `/home/ubuntu/fdroid/repo/`, update the F-Droid metadata
changelog, run `fdroid update` in `/home/ubuntu/fdroid`, then rsync `repo/`
to `/var/www/fdroid/repo/`.
5. Update `public/download/index.html` with the new version/checksum and deploy
the web page.
6. Verify: latest Forgejo release exposes one `.apk`; static page 200;
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`; Obtanium still resolves the latest release.
7. Keep Forgejo/F-Droid credentials and the F-Droid repo keystore out of git.
Local backups: `~/.config/skatespots/forgejo-admin.txt` and
`~/.config/skatespots/fdroid-repo/` (mode 600).
### Nostr-only distribution (no external identities)
- **Zapstore** is the Nostr-native app store/update channel; keep publishing
release events there.
- **Obtanium** is URL-based, not an account: users add the latest Forgejo
release URL (HTML source if needed) or the self-hosted F-Droid repo.
- **F-Droid main and IzzyOnDroid do not support Nostr identities.** They are
intentionally not pursued. Do not create or request GitLab/Codeberg/email
accounts, and do not claim those stores are live.
- The local Forgejo admin account, the relay, and the F-Droid repo signing key
are self-hosted infrastructure, not third-party identities.
- The F-Droid recipe remains in `distribution/fdroiddata/` only as an unused
technical reference. It was locally test-built at `5ebf741`, but must not be
submitted.
### Deploy
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
@ -2544,6 +2686,191 @@ Rules:
is included in that single envelope-only count — muted threads still
cannot be subtracted without decrypting). APK sha256 `1b3c7379…`,
versionCode 10727. Web forced live at `d6f8345`.
- **1.7.28 (2026-10-29):** `/c` now redirects into the app Community room
instead of Cordn web; community join copy names the bot/admin, adds
**Request again**, a second-device **Link device** hint and an admin
pending-request banner. The community bot now retires handled/stale
requests (fixes endless "Join request sent"/already-member retries),
prefers `kp_ref`, logs pending queues, and caps at 250 members / 5
devices per account. APK sha256 `03d47681…`, versionCode 10728. Web
forced live at `69ce773`.
- **1.7.29 (2026-10-29):** Advanced Cordn web link now warns that Cordn
web may stall; admins get a “Request handled” confirmation. Added
`scripts/deploy-community-bot.sh` (build → backup → upload → restart →
refresh the local 0600 state backup) and `ops-health.sh` now checks the
remote `skatespots-community` service. APK sha256 `c6f82fb5…`, versionCode
10729. Web forced live at `e02f2b4`.
- **1.7.30 (2026-10-29):** `/cordn` also redirects to the app; the external
Cordn open link is removed (copy-only). Approving a device now asks for
confirmation. Corrected the second-device hint (Link device or re-request
with the new device) and added the bot’s 30/hour approval brake plus daily
30-snapshot local/restic state backups. APK sha256 `51dfe67e…`, versionCode
10730. Web forced live at `bde3d2484`.
- **1.7.31 (2026-10-29):** animated orange-wheel launch splash (roll-in +
spin + sparks; reduced-motion static), native Android splash regenerated
from `resources/splash.png` instead of the stock blue placeholder, Cordn
**Private rooms** in Messages (Community + crews, Open/Set up, local
unread dots), per-room mute, in-room search, coordinator last-checked,
Chat-vs-Private explainer, approve/remove confirmations, and a device-local
Cordn outbox with bounded auto-retry. APK sha256 `d84b57de…`, versionCode
10731. Web forced live (manual `netlify deploy`; the release-script deploy
hit a transient build failure).
- **1.7.32 (2026-10-29):** sponsor-ready profiles — Follow/Unfollow,
Followers/Following counts, Events hosted (`useUserEvents`), a balanced
six-cell stats strip, and a sponsor/collab card with audience stats,
prefilled collab DM and Rider Card link. NIP-05 shows a verifying state;
public stash images use the shared fallback. APK sha256 `31aced78…`,
versionCode 10732. Web forced live (manual `netlify deploy`; the
release-script deploy still hits the Netlify build failure).
- **1.7.33 (2026-10-29):** explicit profile **Open to sponsorships** opt-in
(`sponsorships_open` + `sponsorships_note` in kind-0; only shown when
opted in), plus a Bitcoin grantmaker inquiry form on `/supporters`.
The form opens a private DM to the owner and stores nothing; its funny
disclosure says KYC is for grantees, not for SkateSpots. Followers stat
now has a “relay-reported, not audited” info hint. APK sha256
`fd6ffb8b…`, versionCode 10733. Web forced live (manual
`netlify deploy`; the release-script deploy still hits the Netlify build
failure).
- **1.7.34 (2026-10-29):** follower stat layout fixed (2/3/6 responsive
columns; relay-reported caveat moved to a footnote instead of an edge
tooltip), and the Bitcoin grantmaker section gained explicit ethos and
privacy boundaries (no KYC of SkateSpots, no tracking/CRM, private
NIP-17 DM, open-source-only grants). APK sha256 `a23e8c3d…`, versionCode
10734. Web forced live at `d3d82f7`.
- **1.7.35 (2026-10-29):** replaced the floating mobile “Links” pill over
the bottom nav with a slim always-visible footer strip. APK sha256
`d3cfdc3d…`, versionCode 10735. Web forced live at `5d40a67`.
- **1.7.36 (2026-10-29):** new **Transport / Driver** Lab craft (kind
`35016`, self-tag `skatetransport`) — vehicles, services, capabilities,
capacity and coverage; `/lab?craft=transport` filters; a self-attested
notice (no licence/insurance/operator verification); WoT/vouches/reviews
inherited from the shared Lab detail; stats/impact counts. All 24 locales
100%. APK sha256 `53286e0f…`, versionCode 10736. Web forced live at
`b0f6dd9`.
- **1.7.37 (2026-10-29):** Cordn coordinator client tolerates empty tool
responses: reads `structuredContent` or JSON text, and falls back to
`{requests:[]}`/`{welcomes:[]}`/`{messages:[]}`/`{keyPackages:[]}` instead
of a Zod “expected object, received undefined” error. APK sha256
`6cacedc0…`, versionCode 10737. Web forced live at `b94ac3d`.
- **1.7.38 (2026-10-29):** audit pass — follower query limit 100→500;
Clothing Designers added to `/stats` + Supporters impact; Sasquatch FAB
hidden on `/become`, create/edit and `/supporters` forms; protobufjs
override 7.6.5 (only remaining production advisory is write-only xlsx).
APK sha256 `c5975f8b…`, versionCode 10738. Web forced live at `41f420a`.
- **1.7.39 (2026-10-30):** replaced write-only SheetJS `xlsx` with a small
local OOXML writer (`lib/xlsx.ts` + fflate); `npm audit --omit=dev` is now
**0 vulnerabilities**. Personal profiles have a **Lab services** strip
linking their Lab profiles. Added parser/XLSX tests (156 tests). APK
sha256 `1202aded…`, versionCode 10739. Web forced live at `6aba9b3`.
- **1.7.40 (2026-10-30):** native boot fix for the white/black reload loop —
`boot-guard.js` and `registerAppServiceWorker` clear stale
`skate-spots-shell-*` service workers/caches on the Capacitor
`https://localhost` origin before the bundle runs. APK sha256 `162488b5…`,
versionCode 10740. Web forced live at `44be542`.
- **1.7.41 (2026-10-30):** app-shell cache bumped to v3; `/boot-guard.js` is
never cached by the worker and web registration uses
`updateViaCache: 'none'`, so the cleanup cannot be trapped by an old
worker. APK sha256 `a32d56bb…`, versionCode 10741. Web forced live at
`c36bf2f`.
- **1.7.42 (2026-10-30):** black document background before CSS (no native
white flash) and a cache-busted boot-guard URL. APK sha256 `5baf7129…`,
versionCode 10742. Web forced live at `7dc8c2c`.
- **1.7.43 (2026-10-30):** boot-guard reload-loop breaker — three loads
within a minute clear the app-shell worker/cache and reload once into a
clean shell. APK sha256 `d25e28a4…`, versionCode 10743. Web forced live at
`1a88f86`.
- **1.7.44 (2026-10-30):** native service-worker cleanup fully
feature-checked/wrapped; it can no longer abort boot on Android WebViews
with a partial Service Worker API. APK sha256 `6193ab1b…`, versionCode
10744. Web forced live at `c66aeb5`.
- **1.7.45 (2026-10-30):** `MapPage` is eager instead of a lazy Suspense
boundary; slow/hung native chunk import could leave the black PageLoader
forever. APK sha256 `bd6a498b…`, versionCode 10745. Web forced live at
`afd5a09`.
- **1.7.46 (2026-10-30):** smoother open transition — logo starts near the
native splash centre and settles into the wordmark column; splash content
lifts/softens on exit while the app stays still. APK sha256 `89c07493…`,
versionCode 10746. Web forced live via manual `netlify deploy` at
`008fb81`.
- **1.7.47 (2026-10-30):** faster open (~0.7s typical, 0.9s cap); logo no
longer spins — it drops in once with a single spark burst — and the exit
is an aperture iris closing into the logo, with no dead black frame.
APK sha256 `d4b0dd74…`, versionCode 10747. Web forced live at `e22e2ec`.
- **1.7.48 (2026-10-30):** Amber decrypt loops now honour query
cancellation — leaving the DM inbox or closing the bell stops the next
Amber prompt instead of continuing through every remaining message.
The seal half of a DM decrypt is cached when a two-step decrypt is cut
short. APK sha256 `a74f21de…`, versionCode 10748. Web forced live at
`3d905af`.
- **1.7.49 (2026-10-30):** Cordn setup is single-flight, so a double-tap on
“Set up” cannot launch overlapping coordinator/Amber flows. Amber users
see the two real setup steps (device key → join request); each coordinator
request is a separate approval. APK sha256 `9dee0c4d…`, versionCode 10749.
Web forced live at `7958ca5`.
- **1.7.50 (2026-10-30):** performance/navigation/Amber polish. Web entry
shrank from ~863 KB to ~648 KB gzip: the map is web-lazy (native remains
eager via a Capacitor Vite alias), the Cashu/Zap dialog is loaded on first
tap, and primary routes prefetch on hover/touch. Page-level Suspense keeps
the header/bottom nav mounted with a delayed skeleton instead of a
full-screen black loader; the burger closes on route changes. Native Amber
calls are serialized/coalesced and decrypt results are memory-cached.
APK sha256 `d338c158…`, versionCode 10750. Web live at `65a7f0b`.
Production dependency audit: 0 vulnerabilities.
- **1.7.51 (2026-10-30):** profile/payment polish. The profile tip card no
longer renders empty when neither method exists; Lightning addresses accept
a pasted `lightning:` prefix and the profile row is tap-to-copy; CLINK codes
are canonicalised on save in profile/shop/crew/Lab forms; marketplace
listing detail surfaces a seller CLINK tip when their profile has one.
CLINK remains a wallet handoff, not an in-app rail. APK sha256 `ac433d91…`,
versionCode 10751. Web live at `c65e6ef`.
- **1.7.52 (2026-10-30):** compact tabbed `/supporters` page (Support /
Supporters / Community / Grants). The three funding rails are now a
one-at-a-time selector; impact, pay-for cards, partners/friends and the
grantmaker form keep all content with tighter mobile spacing. `/support`
redirects to `/supporters`. APK sha256 `acd581a2…`, versionCode 10752.
Web live at `69a8649`.
- **1.7.53 (2026-10-30):** Cordn/Staircase reliability. Newlay coordinator
budget raised (300/min, burst 120, concurrency 64), client polling now
backs off with jitter and polls hidden tabs half as often; relay health
alerts cover Cordn 429s/announce/TLS; Settings shows per-relay health pills;
Crew coordinator card adds a Staircase/Cordn QR. APK sha256 `f124669c…`,
versionCode 10753. Web live at `95ff486`.
- **1.7.54 (2026-10-30):** image-only Blossom quotas. Newlay per-object cap
10 MiB, signed per-npub quota proxy (50/250 MiB daily, admins 3×, per-IP
hourly backstop, 20 GB relay cap), app-side image/type/size guard and clear
errors; uploads use only our Blossom (mirrors are read-only fallbacks).
APK sha256 `f554d934…`, versionCode 10754. Web live at `465683c`.
- **1.7.55 (2026-10-30):** private read-only `/ops` status page for the two
relay admins, NIP-98-signed status endpoint fed by the host health timer;
admin link in Settings → Advanced. Added HSTS to relay/blossom and CAA
records limiting their certificates to Let’s Encrypt. APK sha256
`1142c425…`, versionCode 10755. Web live at `52a8447`.
- **1.7.56 (2026-10-30):** mobile nav simplified to five intents — Spots,
The Lab, Events, Crews, More. Profile/login stays in the header avatar;
Mints and all secondary sections moved to More. Customizer edits three
middle slots; desktop default primary tabs use Community instead of Mints.
APK sha256 `ec15b80d…`, versionCode 10756. Web live at `cdf4a63`.
- **1.7.57 (2026-10-30):** mobile header avatar opens the user’s public
profile; the adjacent chevron opens the account menu. Removed the
redundant “IN” label so the header fits at 390px. APK sha256 `846efb24…`,
versionCode 10757. Web live at `5026b63`.
- **1.7.58 (2026-10-30):** the top-left SkateSpots brand lockup opens the
signed-in user’s profile on mobile and desktop; signed out it still opens
the map. Bottom Spots / desktop Map remains the home shortcut. APK sha256
`0d7bdad7…`, versionCode 10758. Web live at `74bd140`.
- **1.7.59 (2026-10-30):** owner/admin Delete in spot/shop card menus; mobile
Back arrow on every non-map screen (history fallback to map); avatar account
menu trimmed to status/profile/settings/accounts/logout; brand wordmark
hidden on sub-pages for narrow-header fit. APK sha256 `0fd4a149…`,
versionCode 10759. Web live at `0355c10`.
- **1.7.60 (2026-10-30):** Cordn header shows last successful check; sellers
see payment-readiness in Create Listing + offers; uploads resize photos to
1600px WebP when smaller (GIF/undecodable formats untouched), cutting
quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760.
Web live at `6c9b6fa`.
- **1.7.61 (2026-10-30):** Cordn setup timeline now checks prepared device
/sent request and spins on the approval step while waiting. APK sha256
`db0279f7…`, versionCode 10761. Web live at `50c95d0`.
### Relay archive (dataset backup)
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
@ -2604,6 +2931,11 @@ Rules:
and enables them. Logs: `journalctl --user -u skatespots-archive`.
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
are **skipped by the deploy timer** → no Netlify credits spent.
- **XLSX writer without SheetJS (2026-10-30):** the only SheetJS use was the
deck-cost **export** (write-only). It is now `src/lib/xlsx.ts` using `fflate`
to emit a minimal OOXML workbook (inline strings, multi-sheet, safe names).
`xlsx` was removed; production `npm audit --omit=dev` is **0**. Keep the
writer write-only; never parse untrusted XLSX with it.
- **Dependency updates + vulnerability scan (2026-10-12):** `scripts/update-deps.sh`
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
@ -2847,6 +3179,11 @@ The community relay moved off the hosted relay.tools instance
cosmetic; unauthenticated reads work. Config backup:
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
`sudo docker compose up -d --force-recreate` from `~/newlay`.
**2026-10-30:** after 3,767 `cordn rate limit` rejects in 24h (`msg_fetch_many`,
mostly the two admin pubkeys), raised `[cordn] rate_per_min` 120→300,
`rate_burst` 30→120 and `max_concurrent_requests` 32→64. Config backup
`newlay.toml.bak-cordn-20261030-102947`; restarted and verified coordinator
announce + NIP-11 200. Recheck the reject count before/after a Staircase soak.
⚠️ **Content can silently miss the canonical relay:** the app publishes to
`getWriteRelays()` and resolves on the **first** relay that accepts, so a
rate-limited community-relay write can leave a spot only on ditto/damus (this
@ -2875,7 +3212,9 @@ The community relay moved off the hosted relay.tools instance
dies, and posts to **ntfy.sh** when the problem set changes (deduped; also a
"recovered" message). The topic is in `/home/ubuntu/skatespots-health.env`
(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked at
`scripts/systemd/skatespots-health.{service,timer}`.
`scripts/systemd/skatespots-health.{service,timer}`. **2026-10-30:** the host
script also alerts on ≥40 Cordn rate-limit rejects/15m, a missing coordinator
announce/20m, and TLS expiry <14d; deployed to `/home/ubuntu/skatespots-health.sh`.
- **Media / Blossom (2026-10-12):** the relay also serves Newlay's Blossom at
**`blossom.skate-spots.org`** (a Caddy site block → the same `127.0.0.1:7777`;
blobs stored at `/var/lib/newlay/data/blobs`, INSIDE the data volume so they
@ -2886,6 +3225,24 @@ The community relay moved off the hosted relay.tools instance
(`lib/imageFallback.ts`) includes it so reads fall back across all three.
Verified end-to-end (signed PUT /upload → 201, GET /<sha> → 200). To fully
self-host media later, drop the two public hosts from both lists.
**2026-10-30 — image-only quota proxy:** `[blossom] max_size_bytes` lowered
to **10 MiB**. Caddy routes only `PUT /upload` through
`skatespots-quota.service` (`scripts/quota-proxy.mjs`, `127.0.0.1:8790`);
reads and all other Blossom routes still go straight to Newlay. The proxy
verifies the BUD-11 token, enforces image types and per-npub daily budgets
(50 uploads/250 MiB; admins 3×), a per-IP hourly backstop (100/500 MiB), and a
20 GB relay-wide cap. State is `/home/ubuntu/skatespots-quota/usage.json`;
the service is enabled, and the host health check checks its `/health`, the
service, and >18 GiB media. App uploads now use **only** our Blossom; public
mirrors are read-side fallbacks only, so a quota rejection cannot silently
land on a third party. No video upload support was added.
**2026-10-30 — transport/ops hardening:** HSTS is set on `relay.` and
`blossom.` (`max-age=31536000; includeSubDomains`); CAA records restrict
issuance for those two hosts to `letsencrypt.org`. The health timer writes an
aggregate `/home/ubuntu/skatespots-quota/status.json`; the quota proxy serves
it at `https://relay.skate-spots.org/ops` only for a NIP-98-signed request
from one of the two admin pubkeys. `/ops` is a read-only app page linked from
Settings → Advanced (AdminReviewCard). No restart/config actions are exposed.
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =

38
NIP.md
View file

@ -14,6 +14,8 @@ other clients, and preserved on edit:
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
| `sponsorships_open` | boolean | Explicit opt-in: the skater is open to sponsorship/collab conversations. Absence means **not** opted in; never infer this from activity. |
| `sponsorships_note` | string | Optional short note shown on the sponsor/collab card when `sponsorships_open` is true. |
| `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=<nprofile>` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. |
| `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. |
| `cashu_mint` | string | Preferred Cashu mint URL for marketplace escrow (`https://…`). Buyers are nudged to lock funds at this mint, since a mint the seller already uses is one they can redeem from. Optional; see `ESCROW.md`. |
@ -29,6 +31,8 @@ Example:
"lud16": "sk8rboi@getalby.com",
"bitcoin_silent_payment": "sp1qq...long...",
"sponsors": "Independent Trucks, Local Skate Co.",
"sponsorships_open": true,
"sponsorships_note": "Open to deck, shoe, travel and event partners.",
"cordn": "https://cordn.net/u/abc123",
"noffer": "noffer1..."
}
@ -1844,6 +1848,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35013 | Event Organizer / Host | `skateorganizer` |
| 35014 | Barber / Hairstylist | `skatebarber` |
| 35015 | Clothing Designer | `skateclothing` |
| 35016 | Transport / Driver | `skatetransport` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -1888,7 +1893,7 @@ below), `p` (collective member pubkeys — see Collective Membership below),
#### Shared enrichment (all Lab crafts)
Every Lab provider kind (35001–35014) may also carry the same craft-agnostic
Every Lab provider kind (35001–35016) may also carry the same craft-agnostic
enrichment tags — how they work, how to pay them, and their media. They are
repeatable, **not relay-indexed** (clients filter them client-side), and parsed
by **`src/lib/labExtras.ts`** (`parseLabExtras`), which the shared profile detail
@ -2417,12 +2422,39 @@ Barber-specific optional tags:
A shop or collective can be a `collective` with member `p` tags, and may link to
its skate crew via an `a` tag (`35006:pubkey:d`).
### Transport / Driver (kind 35016)
Skaters who can move people, gear or cargo — cars, taxis, vans, motorcycles,
helicopters, planes and boats. **Self-attested:** the app does not verify
licences or insurance; trust comes from WoT, vouches, reviews and reports.
Uses the common base schema.
Categories encoded as `t` tags:
- **Vehicles**: `car`, `taxi`, `van`, `moto`, `helicopter`, `plane`, `boat`, `other`
- **Services**: `people`, `goods`, `gear`, `airport`, `venue`, `tours`, `logistics`, `other`
- **Capabilities**: `cargo`, `bike_board`, `wheelchair`, `pets`, `border`, `night`, `covered`, `group`
Transport-specific optional tags:
| Tag | Description |
|-----|-------------|
| `capacity` | Passenger / board capacity note. |
| `cargo` | Cargo space / weight / volume note. |
| `coverage` | Routes, regions or cross-border coverage. |
| `availability` | Freeform availability. |
| `base` | Base area / airport. |
| `accessibility` | Ramp, wheelchair space, assistance, etc. |
A transport company can be a `collective` with member `p` tags, and may link to
its skate crew via an `a` tag (`35006:pubkey:d`).
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber"],
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014, 35015, 35016],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber", "skateclothing", "skatetransport"],
"limit": 100
}
```

97
distribution/README.md Normal file
View file

@ -0,0 +1,97 @@
# Distribution without GitHub
SkateSpots is published from a self-hosted **Forgejo** instance. No GitHub
account is required.
## Current release source
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
- Signed APK (1.7.61):
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.61/SkateSpots-1.7.61.apk>
- SHA-256:
`db0279f759efd9fa05d9d1bed57a3f5856777e6ccb208d3ed78b15398f266fba`
The APK is signed with the same key as the Zapstore build. Installing an
F-Droid-signed build over it would require uninstalling SkateSpots and losing
the local key/settings. Obtanium and direct APK updates keep the install.
## Obtanium
Obtanium cannot use a Zapstore URL as a source. Use the self-hosted Forgejo
release page instead:
1. Open Obtanium → **Add App**.
2. Paste:
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`
3. If Obtanium does not recognise the self-hosted host, select **Override
Source → HTML**.
4. Set version extraction if desired:
`SkateSpots-([0-9.]+)\.apk`, match group `1`.
The release page contains exactly one `.apk`, so Obtanium can follow the latest
release without a custom store listing.
## F-Droid family
### Self-hosted F-Droid repo (live now)
A signed third-party F-Droid repository is published here:
- Repo: <https://fdroid.skate-spots.org/repo>
- App ID: `org.skatespots.app`
- Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`
Users can add this URL in F-Droid, or add it in Obtanium as an **F-Droid Third
Party Repo**. This is not F-Droid main: it is a self-hosted, signed repository
of the developer-signed APK. It keeps the existing install and signing key.
The repository metadata and APK are served from the relay host. The repo index
is signed with a repository key stored on that host; the APK itself keeps the
SkateSpots developer signature.
### No external store identities
**Owner decision:** distribution stays self-hosted and Nostr-based. No GitLab,
Codeberg, GitHub, email or F-Droid-main accounts are created or requested.
F-Droid main and IzzyOnDroid both require external identities and review, so
they are intentionally out of scope. The self-hosted repo above is the supported
F-Droid-family channel.
`distribution/fdroiddata/` and `fastlane/` are kept as an unused technical
reference only. A fresh local clone at `5ebf741` built the unsigned APK through
the recipe's `npm ci → android:init → android:sync → assembleRelease` path, but
the recipe must not be submitted.
## Release checklist
1. `npm test`
2. `scripts/release.sh patch --yes --deploy` (Zapstore + web)
3. Push the release tag and `main` to Forgejo.
4. Upload the APK through the helper:
`FORGEJO_TOKEN=... scripts/publish-forgejo.sh <version>`
(or set `FORGEJO_TOKEN` before `release.sh`; the APK upload is then automatic)
5. Copy the signed APK into the F-Droid repo on the relay host, add a
`metadata/org.skatespots.app.yml` changelog entry, run `fdroid update`, and
sync `repo/` to `/var/www/fdroid/repo/`:
```bash
cd /home/ubuntu/fdroid
cp /path/to/SkateSpots-<version>.apk repo/
. /home/ubuntu/fdroid-venv/bin/activate
fdroid update
sudo rsync -a --delete repo/ /var/www/fdroid/repo/
sudo chown -R caddy:caddy /var/www/fdroid
```
6. Verify the public latest-release page contains the new `.apk`, and that
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`.
7. Update the static download page if the displayed version/checksum changes:
`public/download/index.html`.
The Forgejo instance runs on the same host as the relay. It exposes public
read access and HTTPS through Caddy; registration is disabled and administrative
credentials are kept on the host, not in this repository.

View file

@ -0,0 +1,51 @@
Categories:
- Sports & Health
License: AGPL-3.0-only
AuthorName: SkateSpots
WebSite: https://skate-spots.org
SourceCode: https://git.skate-spots.org/skatespots/SkateSpots
IssueTracker: https://git.skate-spots.org/skatespots/SkateSpots/issues
Changelog: https://git.skate-spots.org/skatespots/SkateSpots/releases
AutoName: SkateSpots
Summary: Local-first skate map, crews, events and marketplace
Description: |-
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew
chats and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles
the same web app for fast shell startup and native camera, location, push and
NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
RepoType: git
Repo: https://git.skate-spots.org/skatespots/SkateSpots.git
Builds:
- versionName: 1.7.61
versionCode: 10761
commit: 5ebf741427303be0b0feeff5ce1d8420e2c0ddea
subdir: .
prebuild:
- npm ci
- npm run android:init
- npm run android:sync
# F-Droid signs the APK itself. Remove the developer signing config so
# Gradle emits an unsigned release APK instead of requiring the keystore.
- sed -i '/signingConfig signingConfigs.release/d' android/app/build.gradle
build:
- cd android && ./gradlew assembleRelease --no-daemon
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
# The generated Android project is intentionally not committed. This
# recipe recreates it with Capacitor before Gradle runs. F-Droid will sign
# the resulting APK with its own key. The F-Droid buildserver supplies the
# JDK/Android SDK; do not install packages with sudo in a recipe.
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.7.61
CurrentVersionCode: 10761

View file

@ -0,0 +1 @@
Cordn setup now shows visible progress: the device-key step is checked once prepared, and the approval step shows a spinner while waiting. The APK uses the same signing key as the Zapstore release.

View file

@ -0,0 +1,15 @@
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew chats
and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles the
same web app for fast offline shell startup and native camera, location, push
and NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
Lightning payments are optional and self-custodial. Transport and driver
profiles are self-attested; SkateSpots does not verify a licence, insurance or
vehicle roadworthiness.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1 @@
Local-first skate map, crews, events and marketplace.

View file

@ -0,0 +1 @@
SkateSpots

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" style="background:#050505">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
@ -80,14 +80,14 @@
<link rel="preconnect" href="https://relay.skate-spots.org" crossorigin>
<link rel="preconnect" href="https://blossom.skate-spots.org" crossorigin>
</head>
<body>
<body style="background:#050505">
<div id="root"></div>
<!--
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
-->
<script src="/boot-guard.js"></script>
<script src="/boot-guard.js?v=20261030"></script>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

@ -23,10 +23,10 @@
Cache-Control = "public, max-age=31536000, immutable"
# Human-friendly short links.
# /community and /c -> the app's Community page (HashRouter route).
# /cordn is deliberately the raw Cordn room reference for Staircase/Cordn
# clients; normal users should use /c, which stays inside the app's own native
# MLS flow (Cordn web currently stalls after NIP-42).
# /community, /c and /cordn -> the app's Community page (HashRouter route).
# The raw room reference is still available via "Copy Cordn link" in Advanced
# for Staircase/Cordn clients, but no browser short link goes to the currently
# stalled Cordn web client.
# Keep these BEFORE the SPA fallback.
[[redirects]]
from = "/community"
@ -42,7 +42,7 @@
[[redirects]]
from = "/cordn"
to = "https://cordn.net/chat/cordn1qgdhwumn8ghj7un9d3shjtnnddshgefdwdcx7arn9ehhyecpyqpgz6s9k82wwznal8r39mm0wfe8wsnf6eda3ghjvt8qxvxs3ptfxqq5wd4kzar9wdcx7arn8f3k7mtdw4hxjare8hqt74"
to = "/#/community"
status = 302
force = true

123
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "skate-spots",
"version": "1.7.28",
"version": "1.7.61",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skate-spots",
"version": "1.7.28",
"version": "1.7.61",
"license": "AGPL-3.0-only",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
@ -89,6 +89,7 @@
"dotenv": "^17.4.2",
"ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8",
@ -120,7 +121,6 @@
"tinyld": "^1.3.4",
"vaul": "^0.9.3",
"websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7"
},
"devDependencies": {
@ -151,6 +151,7 @@
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "0.35.5",
"tailwindcss": "^3.4.11",
"tsx": "^4.22.4",
"typescript": "5.8.3",
@ -2945,12 +2946,6 @@
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
@ -6011,15 +6006,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -7053,19 +7039,6 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
@ -7212,15 +7185,6 @@
"typescript": "^5"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -7334,18 +7298,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -8620,15 +8572,6 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -10925,9 +10868,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -10937,7 +10880,6 @@
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
@ -12272,18 +12214,6 @@
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
@ -14133,24 +14063,6 @@
"bs58check": "^4.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -14252,27 +14164,6 @@
"node": ">=14.17.0"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.7.28",
"version": "1.7.61",
"license": "AGPL-3.0-only",
"type": "module",
"homepage": "https://skate-spots.org",
@ -21,11 +21,11 @@
"check:i18n": "node scripts/check-i18n.mjs",
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
"android:open": "npx cap open android",
"android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
"translation:matrix": "node scripts/translation-matrix.mjs"
@ -111,6 +111,7 @@
"dotenv": "^17.4.2",
"ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8",
@ -142,7 +143,6 @@
"tinyld": "^1.3.4",
"vaul": "^0.9.3",
"websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7"
},
"devDependencies": {
@ -173,6 +173,7 @@
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "0.35.5",
"tailwindcss": "^3.4.11",
"tsx": "^4.22.4",
"typescript": "5.8.3",
@ -183,7 +184,7 @@
},
"overrides": {
"@tanstack/query-core": "$@tanstack/react-query",
"protobufjs": "7.6.3",
"protobufjs": "7.6.5",
"sharp": "0.35.5"
}
}

View file

@ -8,6 +8,71 @@
*/
(function () {
var shown = false;
// Reload-loop breaker. A stale app-shell worker can reload before the boot
// guard's 12s error panel is ever shown. After three loads inside one minute,
// clear the shell cache/worker once and reload into a clean shell.
try {
var ATTEMPT_KEY = 'skatespots:boot-attempts';
var now = Date.now();
var attempts = { count: 0, at: now };
try {
var stored = JSON.parse(sessionStorage.getItem(ATTEMPT_KEY) || 'null');
if (stored && typeof stored.at === 'number' && now - stored.at < 60000) {
attempts.count = Number(stored.count) || 0;
}
} catch (e) {}
attempts.count += 1;
attempts.at = now;
try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(attempts)); } catch (e) {}
if (attempts.count >= 3) {
try { sessionStorage.removeItem(ATTEMPT_KEY); } catch (e) {}
var finished = false;
var finish = function () {
if (finished) return;
finished = true;
location.reload();
};
var jobs = [];
try {
if ('serviceWorker' in navigator) {
jobs.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (reg) { try { reg.unregister(); } catch (e) {} });
}).catch(function () {}));
}
if (window.caches) {
jobs.push(window.caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (key) { return key.indexOf('skate-spots-shell-') === 0; })
.map(function (key) { return window.caches.delete(key).catch(function () {}); }));
}).catch(function () {}));
}
} catch (e) {}
if (jobs.length) Promise.all(jobs).then(finish).catch(finish);
else finish();
setTimeout(finish, 1000);
return;
}
} catch (e) { /* best effort */ }
// Native WebViews can keep a service worker from an older build forever.
// That worker can serve a stale index.html whose hashed chunks no longer
// exist, producing a white/black reload loop. The native shell is served from
// https://localhost; clear its app-shell registrations before the bundle runs.
try {
var isNativeShell = location.protocol === 'https:' && location.hostname === 'localhost';
if (isNativeShell && 'serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (reg) { try { reg.unregister(); } catch (e) {} });
}).catch(function () {});
if (window.caches) {
window.caches.keys().then(function (keys) {
keys.filter(function (key) { return key.indexOf('skate-spots-shell-') === 0; })
.forEach(function (key) { window.caches.delete(key).catch(function () {}); });
}).catch(function () {});
}
}
} catch (e) { /* best effort */ }
function paint(kind, err) {
if (shown) return;
shown = true;

127
public/download/index.html Normal file
View file

@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>SkateSpots APK</title>
<meta
name="description"
content="Download the signed SkateSpots Android APK. No account, no store, no tracking."
/>
<style>
:root {
color-scheme: dark;
--bg: #090b0c;
--panel: #121719;
--line: #273033;
--text: #f2f5f4;
--muted: #9aa7a5;
--teal: #56e0c0;
--amber: #f5b83d;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(86, 224, 192, 0.12), transparent 42%),
var(--bg);
color: var(--text);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
width: min(100%, 560px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}
.eyebrow {
margin: 0 0 10px;
color: var(--teal);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(28px, 7vw, 38px); line-height: 1.02; }
p { margin: 14px 0 0; color: var(--muted); }
a.download {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
padding: 16px 18px;
border-radius: 12px;
background: var(--teal);
color: #06201b;
font-weight: 800;
text-decoration: none;
}
a.download:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
a.download span:last-child { font-size: 20px; }
.meta {
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
code {
overflow-wrap: anywhere;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.links { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 16px; }
.links a { color: var(--amber); font-weight: 700; text-decoration: none; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main>
<p class="eyebrow">SkateSpots · Android</p>
<h1>Get the signed APK.</h1>
<p>
Local-first skating map, crews, events and marketplace. No store account
required. This is the same signing key as the Zapstore and Obtanium
releases, so it updates over an existing install.
</p>
<a
class="download"
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.61/SkateSpots-1.7.61.apk"
rel="noopener"
>
<span>Download SkateSpots-1.7.61.apk · 56 MB</span>
<span aria-hidden="true">↓</span>
</a>
<div class="meta">
<span>Requires Android 7.0 or newer.</span>
<span>SHA-256: <code>db0279f759efd9fa05d9d1bed57a3f5856777e6ccb208d3ed78b15398f266fba</code></span>
<span>Only install APKs from a source you trust. Android will ask you to allow installs from your browser.</span>
</div>
<div class="links">
<a href="https://git.skate-spots.org/skatespots/SkateSpots/releases">All releases</a>
<a href="https://git.skate-spots.org/skatespots/SkateSpots">Source code</a>
<a href="https://skate-spots.org">Open the web app</a>
</div>
<div class="meta">
<span><strong>Obtanium:</strong> add the latest release page above as the app source, or select <em>Override Source → HTML</em> if it is not recognised.</span>
<span><strong>F-Droid repo:</strong> <a href="https://fdroid.skate-spots.org/repo">https://fdroid.skate-spots.org/repo</a> (third-party self-hosted repo; app ID <code>org.skatespots.app</code>).</span>
<span>Repo signing fingerprint: <code>88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75</code></span>
</div>
</main>
</body>
</html>

View file

@ -10,7 +10,7 @@
* navigations are network-first with an index fallback. No API, DM or model
* request is ever stored; only same-origin static bytes.
*/
const SHELL_CACHE = 'skate-spots-shell-v2';
const SHELL_CACHE = 'skate-spots-shell-v3';
const SHELL_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
@ -39,6 +39,12 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/.netlify/')) return;
// Never cache the boot guard: an old cached copy must not be able to trap
// the app in its own stale-worker cleanup path.
if (url.pathname === '/boot-guard.js') {
event.respondWith(fetch(request).catch(() => caches.match(request).then((cached) => cached || Response.error())));
return;
}
if (request.mode === 'navigate') {
event.respondWith(

View file

@ -1,3 +1,381 @@
🧩 Cordn setup is easier to follow
- The setup steps now show progress: preparing this device is checked once the
key package is ready, and the approval step shows a spinner while waiting.
- The private-chat header already shows the last successful check, so “live” and
“still waiting” are distinguishable at a glance.
---
⚡ Cordn status, payment readiness, lighter photos
- Cordn panels now show the last successful check next to the connection status,
so “waiting” and “live” are easier to tell apart.
- Sellers see a payment-readiness card in listing creation and on incoming
offers, with a direct link to add a Lightning address before accepting a deal.
- Photos are resized to max 1600px and converted to WebP before upload when that
makes them smaller. GIFs and formats the WebView cannot decode are left alone.
This cuts upload time, quota use and Blossom storage.
---
🧹 Spot owners can delete from the card menu
- Owner/admin members now get a **Delete** action in the three-dot menu on
spot and shop cards, opening the existing confirmation dialog.
- Added a mobile **Back** arrow on every non-map screen, with history fallback
to the map. The bottom Spots tab remains the home shortcut.
- Trimmed the avatar account menu to purpose-built actions: status, profile,
settings, account switching and logout. The larger navigation stays in the
burger menu.
- Brand wordmark hides on sub-pages so the Back arrow and header controls fit
on narrow phones without truncation.
---
🧭 Brand logo opens your profile
- The top-left SkateSpots lockup now opens your public profile when signed in
(both mobile and desktop). Signed out, it still goes to the map.
- The bottom Spots tab / desktop Map tab remains the home shortcut, so map
access is unchanged.
- The green avatar still opens your profile too; its chevron opens the account
menu.
---
👤 Avatar opens your profile
- The green user avatar in the mobile header now opens your public profile.
- The small chevron beside it opens the account menu (status, add account,
wallet, settings, logout) — the avatar itself is no longer a menu trigger.
- Removed the redundant “IN” label so the brand and header controls fit on
narrow phones.
- The SkateSpots brand mark still goes to the map.
---
🧭 Simpler mobile navigation
- The bottom bar is now exactly five intents: Spots, The Lab, Events, Crews
and More. Profile/login moved to the header avatar, where it already lived.
- Mints and every other secondary section stay in the More menu — nothing was
removed, only moved out of the way.
- The nav customiser now edits three middle slots and previews the fixed More
button. Existing four-slot layouts are normalised to three on read.
- Desktop defaults now show Community instead of Mints as a primary tab.
---
🔐 Private operator status + transport hardening
- New admin-only `/ops` page shows relay/coordinator health, Cordn 429s,
services, disk/memory, media usage and TLS expiry.
- The status endpoint requires a NIP-98 signature from one of the two relay
admin npubs. Nothing is public; the page has no restart/config actions.
- The host health timer now writes an aggregate status snapshot for that
endpoint every five minutes.
- Added HSTS to `relay.skate-spots.org` and `blossom.skate-spots.org`.
- Added CAA records restricting certificate issuance for those two hosts to
Let's Encrypt.
---
🖼️ Image uploads are now bounded by npub, not just IP
- Blossom uploads are image-only: JPEG, PNG, WebP, AVIF, HEIC or GIF, max 10 MB.
No video uploads were added.
- A signed quota proxy now verifies the BUD-11 upload token, then enforces:
• 50 images / 250 MB per npub per day
• 3× that budget for the two relay admins
• a generous 100 images / 500 MB per IP per hour backstop
• a 20 GB relay-wide media budget with health alerts
- Uploads go through our own Blossom only. Public mirrors remain read-side
fallbacks for existing images, but can no longer silently bypass the quota.
- Clear size/type/daily-limit errors replace raw Blossom responses.
- Newlay’s own per-object cap is now 10 MB as a second line of defence.
The private read-only admin dashboard is next; this release is the quota layer.
---
🛰️ Cordn / Staircase reliability pass
- Raised the embedded coordinator’s request budget (120/min → 300/min,
burst 30 → 120) and concurrency (32 → 64) after finding 3,767 rate-limit
rejections in one day.
- Cordn polling now backs off exponentially with jitter after failures, polls
hidden tabs half as often, and clears stale error state on recovery.
- The relay health check now alerts on Cordn rate-limit bursts, a missing
coordinator announcement, and TLS expiry — not just a dead relay.
- Settings → Coordinator shows live health dots for each configured relay.
- Crew coordination card adds a scan-to-add QR for Staircase/Cordn clients.
---
🛹 Support page: compact, tabbed, easier to scan
- The supporters page is now split into four tabs — Support, Supporters,
Community, Grants — instead of one very long scroll.
- The three funding rails are a compact selector: only the chosen rail opens,
while “what your support pays for” stays visible underneath.
- Hero, section headers, impact stats, cards and spacing are tighter on mobile
while keeping all grantmaker transparency and everyone’s credit.
- `/support` now redirects to `/supporters`, so the page is easy to find.
- Donate page keeps its focused single-page layout and still uses the same rail
cards.
---
💸 Profile payments: CLINK + Lightning, properly separated
- The profile tip card no longer appears as an empty box when a skater has no
payment method. Both can now coexist cleanly:
• Lightning address (`you@yourdomain.com`) powers Zaps and direct sats.
• CLINK (`noffer1…`) powers the self-custodial “Tip in Bitcoin” handoff.
- Lightning addresses are normalised (accept a pasted `lightning:` prefix) and
the profile Lightning row is now tap-to-copy.
- CLINK codes are normalised on save everywhere (profile, shops, crews, Lab),
so a pasted `nostr:noffer1…` is stored as the canonical raw code.
- Marketplace listing detail now surfaces the seller’s CLINK tip button when
their profile has a valid CLINK offer.
- CLINK remains an explicit handoff to a CLINK-compatible wallet; it is not
presented as an in-app payment rail. Lightning remains the in-app rail.
---
⚡ Faster shell, smoother menus, safer Amber
- The web entry bundle is ~25% smaller after moving the map to a web-only lazy
route and loading the Cashu/Zap dialog on first tap. Android keeps the map
eager (static) so the native boot path is unchanged.
- Page navigation keeps the header and bottom nav mounted. A short delayed
skeleton fades in under the chrome instead of a full-screen black spinner,
and primary menu destinations prefetch on hover/touch.
- The mobile burger menu now closes on any route change (back, notifications,
global search), and nav links expose aria-current.
- Amber native calls are serialized and coalesced by request. Identical
concurrent decrypts/signs share one result, decrypts are memory-cached, and
overlapping intents can no longer race the 60s native timeout.
- Production dependency audit: 0 vulnerabilities. No risky major upgrades.
---
🔐 Community chat setup: no overlapping Amber requests
- Cordn setup now runs one coordinator operation at a time. A double-tap on
“Set up” can no longer start two publish/join flows and leave one Amber
request orphaned.
- The setup panel now shows the two real steps for Amber users:
Set up this device → Request join. Those are two separate coordinator
signatures, so seeing two prompts is expected; the first publishes this
device’s key package, the second asks the room to approve it.
---
🛑 Amber: no more popups after you navigate away
- The DM inbox and Cordn notification readers decrypt message-by-message. When
you left the screen while Amber was open, the query was cancelled but the loop
kept launching Amber for every remaining message — the app looked possessed.
- Every decrypt loop now checks the query's abort signal before the next message
and stops immediately. Closing the notification bell also stops its decrypt
loop.
- The first half of a two-step DM decrypt is cached, so if you leave mid-request
the next visit only asks once for that message instead of asking twice again.
- Cordn's crew-message fetch now honours the same cancellation rule.
---
⚡ Faster open, no spinning logo
- The cold-start splash now clears in about 0.7s instead of ~1.7s: no artificial
hold, no long relay wait, and a hard 0.9s cap.
- The wheel mark no longer spins. It drops in once, throws a single short spark
burst, then stays still.
- The exit is an aperture-style iris that closes into the logo, revealing the
app from the edges without moving the map. Content stays visible through the
transition, so there is no dead black frame.
- Reduced-motion users get the same faster timing with a plain fade.
---
✨ Smoother app-open transition
- The logo now starts near the native splash’s centred position and settles into
the wordmark/progress column, so the native → web handoff has no jump.
- On exit the splash content lifts and softens while the app revealed beneath
stays still; no map nudge or zoom flash.
---
🚨 Native boot: eager map + safe cleanup
- The home map is now imported eagerly instead of through a lazy Suspense
boundary. A slow/hung native chunk import could leave the app on the black
PageLoader forever; the header and map now start immediately.
- Native service-worker cleanup is feature-checked and can no longer throw
before React mounts.
---
🚨 Critical native boot fix
- Native service-worker cleanup is now fully feature-checked and wrapped.
Some Android WebViews expose a partial Service Worker API; the cleanup could
throw before React mounted, leaving the splash/logo then a black screen.
- Boot now continues even when that cleanup is unsupported. The cleanup remains
best-effort.
---
🚨 Boot-loop breaker
- If the app reloads three times within a minute, the boot guard now clears the
old app-shell worker/caches once and reloads into a clean shell before the
error panel can be trapped. Normal opens and reloads are unaffected.
---
🩹 Remove the white flash from native boot
- The document background is black before CSS loads, so a native launch can no
longer flash white between the splash and the app shell.
- `boot-guard.js` uses a cache-busting query and is never cached by the app-shell
worker, so the old-worker cleanup is always the new code.
---
🩹 Stronger native boot recovery
- Bumped the app-shell worker cache to v3; old shell caches are deleted on
activate.
- `boot-guard.js` is no longer cached by the service worker, so a stale worker
can never trap the app in its own cleanup path.
- Native WebViews unregister old app-shell workers and clear their caches before
the bundle runs. Web registrations use updateViaCache: none.
---
🩹 Fix native white/black reload loop
- Native installs now clear any old app-shell service worker and
`skate-spots-shell-*` cache from `https://localhost` before the bundle runs.
A stale worker could serve an old index.html whose hashed chunks no longer
existed, causing the white/black reload loop after an upgrade.
- Web installs are unchanged; the app-shell worker still updates normally.
---
🧪 Zero known production vulnerabilities + profile services
- Replaced the write-only SheetJS xlsx package with a small local OOXML writer.
`npm audit --omit=dev` is now **0 vulnerabilities**.
- Personal profiles now show a **Lab services** strip linking to their
Transport, coaching, design, building and other Lab profiles.
- Added tests for the Transport parser and XLSX writer (156 tests total).
---
🔍 Audit pass: counts, forms, dependencies
- Followers now query up to 500 follow lists instead of 100, so profile and
sponsor counts are less likely to undercount.
- Clothing designers were missing from /stats and Supporters impact; they now
have their own count and card (15 Lab crafts total).
- The floating Sasquatch bubble is hidden on /become forms, create/edit forms
and /supporters so it can't cover inputs or the grantmaker form.
- protobufjs override moved to 7.6.5, clearing the moderate ONNX-path advisory.
The only remaining production advisory is xlsx (write-only export, no fix
published).
---
🛠 Cordn empty-response fix
- The coordinator client now accepts both structuredContent and JSON text
responses, and treats an empty list response as an empty list. This fixes the
“expected object, received undefined” error when a coordinator omits the
structured field on an empty join-request/welcome/message result.
---
🚐 Transport / Driver profiles in The Lab
- New Transport craft for skaters who move people, gear or cargo:
cars, taxis, vans, motorcycles, helicopters, planes and boats.
- Services: people, goods/cargo, skate gear, airport and venue transfers,
tours and event logistics.
- Capabilities, capacity and coverage filters on /lab: cargo, bike/board rack,
wheelchair access, pets, cross-border, night driving, covered, group size,
local/regional/national/international.
- Capacity, cargo, coverage, availability, base area and accessibility fields.
- WoT badge, crew link, vouches, reviews and the normal report/hide flow.
- Explicitly self-attested: SkateSpots does not verify licences, insurance,
vehicle roadworthiness or operator permits. A visible notice says so.
- Transport counts on /stats and Supporters impact.
- All Transport copy is in all 24 languages at 100%.
---
🗺 Cleaner map footer + follower stat fit
- The grey floating “Links” pill between Events and Crews is gone. The map
footer is now a slim, always-visible strip above the bottom nav.
- Profile stats use 2/3/6 responsive columns; the Followers label and caveat
no longer overflow or open an edge tooltip.
- The Bitcoin grantmaker section now states its ethos and privacy boundaries:
no KYC of SkateSpots, no tracking/CRM, private NIP-17 DM, and only grants
that keep the project open source, self-hosted and analytics-free.
---
🧾 Grantmaker transparency + stat fit
- The Followers stat no longer overflows its card: stats use 2/3/6 columns,
and the relay-reported caveat is a proper footnote instead of an edge tooltip.
- The Bitcoin grantmaker section now spells out the ethos and the boundaries:
no KYC of SkateSpots, no tracking/CRM, private NIP-17 DM, and only grants
that keep the project open source, self-hosted and analytics-free.
---
💸 Sponsorship opt-in + Bitcoin grantmakers
- Profiles have an explicit “Open to sponsorships” switch and optional note;
the badge and sponsor card only claim it after the skater opts in.
- The Supporters page now has a Bitcoin grantmaker inquiry form. It opens a
private DM to the owner and stores nothing: not to KYC us — to KYC YOU.
It is only for grantmakers that KYC/KYB their grantees.
- The Followers stat carries an info tooltip: relay-reported, not audited
analytics.
---
👤 Sponsor-ready profiles
- Profiles now have Follow/Unfollow, Followers and Following counts, and an
Events hosted section.
- New sponsor/collab card shows followers, events, spots and zaps received,
with a prefilled collab DM and a direct link to the skater's Rider Card.
- Stats strip is a balanced six cells: Spots, Shops, Crews, Events, Gear and
Followers.
- NIP-05 now shows a real verifying state instead of briefly looking unverified.
- Public stash images use the same resilient image fallback as the rest of the app.
---
🛹 Rolling logo, Cordn inbox, safer sends
- The app now opens on the orange SkateSpots wheel: it rolls in, keeps
spinning and throws sparks. Reduced-motion users get the clean static mark.
- Android's launch screen is regenerated from the same source instead of the
stock placeholder, so the first frame is on-brand too.
- Messages gains Private Rooms: Community plus every crew MLS room, with
Open/Set up state and local unread dots.
- Cordn rooms now have per-room mute, in-room message search, Chat vs Private
chat guidance and a last-checked coordinator status.
- Approving or removing a device asks for confirmation.
- Failed Cordn sends are kept in a device-local outbox, retried a few times
when online, and can be retried or discarded later.
- The mobile wordmark no longer truncates in the logged-in header.
---
🔒 Community join hardening
- /cordn now opens the in-app Community room too; the raw Cordn link is
copy-only from Advanced, so no browser short link lands in the stalled Cordn
web client.
- Approving a device now asks for confirmation before adding it to the
encrypted room.
- Corrected the second-device hint: use Link device for a direct handoff, or
ask to join again with the new device.
- The community bot enforces an hourly approval brake (30/hour) on top of the
250-member and 5-devices-per-account caps.
- Daily encrypted state backups now run via a user timer: 30 local snapshots
plus restic when configured. ops-health reports the backup timer.
---
🧵 Community polish + host tooling
- The Advanced Cordn web link now warns that Cordn web may stall after sign-in;
the in-app join remains the reliable path.
- Admins get a “Request handled” confirmation after clearing a join request.
- Added scripts/deploy-community-bot.sh: builds, backs up, uploads, restarts
skatespots-community and refreshes the local 0600 state backup.
- ops-health now reports whether the community Cordn bot is active.
---
💬 Community joins that actually finish
- skate-spots.org/c now opens the in-app Community room instead of Cordn web,
which currently stalls after NIP-42. /cordn remains the raw Cordn reference

BIN
resources/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Pull the SkateSpots Community Cordn state off the relay host, keep 30 daily
# snapshots locally, refresh the 0600 working copy, and optionally push the
# snapshot directory to restic when the normal relay backup is configured.
#
# scripts/backup-community-state.sh
# SKATESPOTS_COMMUNITY_HOST=skatespots-cvm scripts/backup-community-state.sh
set -euo pipefail
HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
REMOTE_STATE="${SKATESPOTS_COMMUNITY_STATE:-/var/lib/skatespots-community/state.json}"
DEST="${SKATESPOTS_COMMUNITY_BACKUP_DIR:-$HOME/.backups/community}"
CONFIG_DIR="$HOME/.config/skatespots"
mkdir -p "$DEST" "$CONFIG_DIR"
chmod 700 "$DEST" "$CONFIG_DIR"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
scp -q "$HOST:$REMOTE_STATE" "$tmp"
# Refuse to rotate a valid backup out for an empty/corrupt download.
node -e 'const fs=require("fs");const s=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(!s||typeof s!=="object")process.exit(1)' "$tmp"
stamp="$(date +%F)"
install -m 600 "$tmp" "$DEST/state-$stamp.json"
install -m 600 "$tmp" "$CONFIG_DIR/community-state.json"
echo "saved $DEST/state-$stamp.json"
# Keep 30 daily snapshots.
ls -1t "$DEST"/state-*.json 2>/dev/null | tail -n +31 | xargs -r rm -f
if [[ -f "$CONFIG_DIR/restic.env" ]] && command -v restic >/dev/null 2>&1; then
set -a
# shellcheck disable=SC1091
. "$CONFIG_DIR/restic.env"
set +a
restic backup "$DEST" --tag community-state --host "$(hostname)"
restic forget --tag community-state --keep-daily 30 --prune
else
echo "restic not configured; local snapshots only"
fi

View file

@ -23,6 +23,7 @@
* COMMUNITY_AUTO_APPROVE=0 leave requests for admins instead of adding
* COMMUNITY_MAX_MEMBERS stop auto-approving at this group size (default 250)
* COMMUNITY_MAX_DEVICES_PER_PUBKEY per-account device cap (default 5)
* COMMUNITY_MAX_APPROVALS_PER_HOUR flood brake for auto-approval (default 30)
* COMMUNITY_ONCE=1 one cycle then exit (for a dry run)
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@ -52,6 +53,10 @@ const parsedMaxDevices = Number(process.env.COMMUNITY_MAX_DEVICES_PER_PUBKEY ??
const MAX_DEVICES_PER_PUBKEY = Number.isFinite(parsedMaxDevices) && parsedMaxDevices >= 1
? Math.floor(parsedMaxDevices)
: 5;
const parsedMaxPerHour = Number(process.env.COMMUNITY_MAX_APPROVALS_PER_HOUR ?? 30);
const MAX_APPROVALS_PER_HOUR = Number.isFinite(parsedMaxPerHour) && parsedMaxPerHour >= 1
? Math.floor(parsedMaxPerHour)
: 30;
const CREATE = process.env.COMMUNITY_CREATE === '1';
const ONCE = process.env.COMMUNITY_ONCE === '1';
@ -139,6 +144,8 @@ let lastJoinRequestAt = 0;
let lastError = '';
let lastPending = 0;
let capLogged = false;
let hourCapLogged = false;
const approvalTimes: number[] = [];
const deviceCapLogged = new Set<string>();
async function cycle(): Promise<void> {
@ -190,6 +197,20 @@ async function cycle(): Promise<void> {
log('member cap cleared; auto-approval resumed');
}
const hourAgo = Date.now() - 60 * 60_000;
while (approvalTimes.length > 0 && (approvalTimes[0] ?? 0) < hourAgo) approvalTimes.shift();
if (approvalTimes.length >= MAX_APPROVALS_PER_HOUR) {
if (!hourCapLogged) {
hourCapLogged = true;
log('hourly approval cap reached', approvalTimes.length, '/', MAX_APPROVALS_PER_HOUR, '— leaving requests for admins');
}
return;
}
if (hourCapLogged) {
hourCapLogged = false;
log('hourly approval cap cleared; auto-approval resumed');
}
const handled: Array<{ pk: string; at: number }> = [];
for (const request of requests.slice(0, 10)) {
if (!/^[0-9a-f]{64}$/i.test(request.pk)) {
@ -230,6 +251,7 @@ async function cycle(): Promise<void> {
}
}
approved.set(approvalKey, Date.now());
approvalTimes.push(Date.now());
handled.push({ pk: request.pk, at: request.at });
log('approved member', short(request.pk));
await sleep(1500); // be gentle with the coordinator
@ -298,6 +320,7 @@ async function main(): Promise<void> {
'auto-approve', AUTO_APPROVE ? 'on' : 'off',
'max', MAX_MEMBERS,
'devices/account', MAX_DEVICES_PER_PUBKEY,
'approvals/hour', MAX_APPROVALS_PER_HOUR,
);
do {

53
scripts/deploy-community-bot.sh Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Build + deploy the SkateSpots Community Cordn bot to the relay host.
#
# scripts/deploy-community-bot.sh
# scripts/deploy-community-bot.sh --no-build
#
# The host defaults to the ssh alias `skatespots-cvm` (same VPS as the relay).
# Override with SKATESPOTS_COMMUNITY_HOST / SKATESPOTS_COMMUNITY_DIR.
set -euo pipefail
cd "$(dirname "$0")/.."
HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
REMOTE_DIR="${SKATESPOTS_COMMUNITY_DIR:-/opt/skatespots-community}"
REMOTE_STATE="${SKATESPOTS_COMMUNITY_STATE:-/var/lib/skatespots-community/state.json}"
ARTIFACT="build/community-bot.cjs"
if [[ "${1:-}" != "--no-build" ]]; then
scripts/build-community-bot.sh
fi
[[ -f "$ARTIFACT" ]] || { echo "missing $ARTIFACT (run without --no-build first)" >&2; exit 1; }
echo "→ uploading to $HOST:$REMOTE_DIR"
scp -q "$ARTIFACT" "$HOST:/tmp/community-bot.cjs.new"
echo "→ installing + restarting skatespots-community"
ssh "$HOST" "REMOTE_DIR='$REMOTE_DIR' bash -s" <<'REMOTE'
set -euo pipefail
cd "$REMOTE_DIR"
sudo -n cp community-bot.cjs "community-bot.cjs.bak-$(date +%Y%m%d-%H%M%S)"
sudo -n mv /tmp/community-bot.cjs.new community-bot.cjs
sudo -n chmod 644 community-bot.cjs
# Keep the five newest backups.
ls -1t community-bot.cjs.bak-* 2>/dev/null | tail -n +6 | xargs -r sudo -n rm -f
sudo -n systemctl restart skatespots-community
sleep 3
if ! systemctl is-active --quiet skatespots-community; then
echo "✗ skatespots-community is not active" >&2
journalctl -u skatespots-community -n 30 --no-pager >&2 || true
exit 1
fi
echo "✓ skatespots-community active"
journalctl -u skatespots-community -n 8 --no-pager | tail -8
REMOTE
echo "→ refreshing the 0600 state backup in ~/.config/skatespots"
mkdir -p "$HOME/.config/skatespots"
chmod 700 "$HOME/.config/skatespots"
if scp -q "$HOST:$REMOTE_STATE" "$HOME/.config/skatespots/community-state.json"; then
chmod 600 "$HOME/.config/skatespots/community-state.json"
echo "✓ state backup updated"
else
echo "⚠ could not copy the bot state (service is still running)" >&2
fi

View file

@ -35,18 +35,92 @@ svc skatespots-relay.service
svc skatespots-ai.service
svc skatespots-push.service
svc skatespots-cvm.service
svc skatespots-quota.service
svc caddy.service
[ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \
&& ISSUES+=("relay public TLS not answering 200")
[ "$(code https://push.skate-spots.org/health)" != "200" ] \
&& ISSUES+=("push /health not 200")
[ "$(code http://127.0.0.1:8790/health)" != "200" ] \
&& ISSUES+=("blossom quota proxy /health not 200")
disk=$(df --output=pcent / | tail -1 | tr -d ' %')
[ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used")
avail=$(free -m | awk '/Mem:/{print $7}')
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available")
# Cordn/Staircase RPC health. Newlay logs one warning per rejected request, so
# a burst is easy to miss in routine log noise. Surface it as an operational
# problem before users experience a stuck private chat.
cordn_rejects=$(docker logs --since 15m newlay-newlay-1 2>&1 | grep -c 'cordn rate limit' || true)
[ "${cordn_rejects:-0}" -ge 40 ] && ISSUES+=("cordn rate limits: ${cordn_rejects} rejects in 15m")
# `grep -q` + pipefail makes `docker logs` exit 141 on SIGPIPE, which used to
# make this check fire even when an announcement was present. Count instead.
cordn_announce=$(docker logs --since 20m newlay-newlay-1 2>&1 | grep -c 'cordn announce' || true)
[ "${cordn_announce:-0}" -eq 0 ] && ISSUES+=("cordn coordinator has not announced in 20m")
# Blossom media budget (usage is persisted by the quota proxy).
quota_state=/home/ubuntu/skatespots-quota/usage.json
if [ -f "$quota_state" ]; then
total_bytes=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("totalBytes",0))' "$quota_state" 2>/dev/null || echo 0)
[ "${total_bytes:-0}" -ge 19327352832 ] && ISSUES+=("blossom media >18 GiB")
fi
# TLS expiry (Caddy auto-renews; this catches a broken renewal early).
expiry=$(echo | openssl s_client -servername relay.skate-spots.org -connect relay.skate-spots.org:443 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$expiry" ]; then
days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 ))
[ "$days" -lt 14 ] && ISSUES+=("relay TLS expires in ${days}d")
else
ISSUES+=("could not read relay TLS certificate")
fi
# Machine-readable snapshot for the signed admin dashboard. Aggregate numbers
# only: no IPs, pubkeys, paths or secrets.
local_relay=$(code -H 'Accept: application/nostr+json' http://127.0.0.1:7777/)
public_relay=$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)
push_status=$(code https://push.skate-spots.org/health)
quota_status=$(code http://127.0.0.1:8790/health)
version=$(curl -s -m 8 -H 'Accept: application/nostr+json' https://relay.skate-spots.org/ \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("version",""))' 2>/dev/null || true)
svc_on() { systemctl is-active --quiet "$1" && echo true || echo false; }
STATUS_DISK="${disk:-0}" STATUS_AVAIL="${avail:-0}" STATUS_MEDIA="${total_bytes:-0}" \
STATUS_CORDN_REJECTS="${cordn_rejects:-0}" STATUS_CORDN_ANNOUNCE="${cordn_announce:-0}" \
STATUS_TLS_DAYS="${days:-0}" STATUS_LOCAL="${local_relay:-000}" STATUS_PUBLIC="${public_relay:-000}" \
STATUS_PUSH="${push_status:-000}" STATUS_QUOTA="${quota_status:-000}" STATUS_VERSION="$version" \
STATUS_SERVICES="$(printf 'relay=%s\nai=%s\npush=%s\ncvm=%s\nquota=%s\ncaddy=%s\n' \
"$(svc_on skatespots-relay.service)" "$(svc_on skatespots-ai.service)" \
"$(svc_on skatespots-push.service)" "$(svc_on skatespots-cvm.service)" \
"$(svc_on skatespots-quota.service)" "$(svc_on caddy.service)")" \
STATUS_ISSUES="$(printf '%s\n' "${ISSUES[@]}")" \
python3 - <<'PY'
import datetime, json, os
services = {}
for line in os.environ.get('STATUS_SERVICES','').splitlines():
if '=' in line:
k,v = line.split('=',1); services[k] = v == 'true'
issues = [x for x in os.environ.get('STATUS_ISSUES','').splitlines() if x]
payload = {
'updatedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'relay': {'local': os.environ.get('STATUS_LOCAL'), 'public': os.environ.get('STATUS_PUBLIC')},
'version': os.environ.get('STATUS_VERSION',''),
'cordn': {'announces20m': int(os.environ.get('STATUS_CORDN_ANNOUNCE','0') or 0),
'rejects15m': int(os.environ.get('STATUS_CORDN_REJECTS','0') or 0)},
'storage': {'diskPct': int(os.environ.get('STATUS_DISK','0') or 0),
'memAvailMiB': int(os.environ.get('STATUS_AVAIL','0') or 0),
'mediaBytes': int(os.environ.get('STATUS_MEDIA','0') or 0)},
'tls': {'days': int(os.environ.get('STATUS_TLS_DAYS','0') or 0)},
'services': services,
'issues': issues,
}
path = '/home/ubuntu/skatespots-quota/status.json'
with open(path,'w') as f: json.dump(payload,f)
os.chmod(path, 0o644)
PY
sig=""
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
prev=""

View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Install the daily community-state backup timer for the current user.
set -euo pipefail
cd "$(dirname "$0")/.."
UNIT_DIR="$HOME/.config/systemd/user"
mkdir -p "$UNIT_DIR"
cp scripts/systemd/skatespots-community-backup.service "$UNIT_DIR/"
cp scripts/systemd/skatespots-community-backup.timer "$UNIT_DIR/"
systemctl --user daemon-reload
systemctl --user enable --now skatespots-community-backup.timer
systemctl --user list-timers skatespots-community-backup.timer --no-pager

View file

@ -0,0 +1,62 @@
/**
* Generate Android launch-screen bitmaps from the tracked brand source.
*
* `resources/splash.png` is the orange SkateSpots wheel. Capacitor's generated
* `android/` tree ships a stock blue placeholder, so every release must rebuild
* these drawables. Keeps the native first frame on-brand before the animated
* web splash takes over.
*/
import { mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import sharp from 'sharp';
const ROOT = resolve(import.meta.dirname, '..');
const SOURCE = resolve(ROOT, 'resources/splash.png');
const RES = resolve(ROOT, 'android/app/src/main/res');
const TARGETS = [
['drawable/splash.png', 480, 320],
['drawable-land-mdpi/splash.png', 480, 320],
['drawable-land-hdpi/splash.png', 800, 480],
['drawable-land-xhdpi/splash.png', 1280, 720],
['drawable-land-xxhdpi/splash.png', 1600, 960],
['drawable-land-xxxhdpi/splash.png', 1920, 1280],
['drawable-port-mdpi/splash.png', 320, 480],
['drawable-port-hdpi/splash.png', 480, 800],
['drawable-port-xhdpi/splash.png', 720, 1280],
['drawable-port-xxhdpi/splash.png', 960, 1600],
['drawable-port-xxxhdpi/splash.png', 1280, 1920],
];
const glow = (width, height) => Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="g" cx="50%" cy="50%" r="38%">
<stop offset="0" stop-color="#ff6a00" stop-opacity="0.30"/>
<stop offset="0.55" stop-color="#ff0040" stop-opacity="0.08"/>
<stop offset="1" stop-color="#050505" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#g)"/>
</svg>`);
for (const [relative, width, height] of TARGETS) {
const logoSize = Math.max(64, Math.round(Math.min(width, height) * 0.3));
const logo = await sharp(SOURCE)
.resize(logoSize, logoSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
const out = resolve(RES, relative);
await mkdir(dirname(out), { recursive: true });
await sharp({
create: { width, height, channels: 4, background: '#050505' },
})
.composite([
{ input: glow(width, height), blend: 'over' },
{ input: logo, gravity: 'center' },
])
.png()
.toFile(out);
console.log(`splash ${relative} ${width}x${height}`);
}

View file

@ -213,6 +213,29 @@ if [ -f "$RUN_FILE" ]; then
fi
fi
# ── Community Cordn bot ─────────────────────────────────────────────────────
p ""
p "${B}Community bot${N}"
COMMUNITY_HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
if command -v ssh >/dev/null 2>&1 && grep -qsE "^Host[[:space:]]+${COMMUNITY_HOST}([[:space:]]|$)" "$HOME/.ssh/config"; then
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
if [ "$community_state" = "active" ]; then
p " ${G}✓${N} skatespots-community active"
else
p " ${R}✗${N} skatespots-community: ${community_state:-unreachable}"
problems+=("community Cordn bot is ${community_state:-unreachable}")
signature+="community-bot;"
fi
community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)"
if [ "$community_backup" = "active" ]; then
p " ${G}✓${N} community state backup timer active"
else
p " ${Y}⚠${N} community state backup timer: ${community_backup}"
fi
else
p " ${DIM}ssh alias ${COMMUNITY_HOST} not configured — skipped${N}"
fi
# ── Dependency job ──────────────────────────────────────────────────────────
p ""
p "${B}Dependency job${N}"

108
scripts/publish-forgejo.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# Publish a built APK to the self-hosted Forgejo release page.
#
# Usage:
# FORGEJO_TOKEN=... scripts/publish-forgejo.sh 1.7.62 [path/to.apk]
# FORGEJO_PASSWORD=... FORGEJO_USER=skatespots scripts/publish-forgejo.sh 1.7.62
#
# This uploads the APK and its .sha256 file. It does not push the git tag or
# source; do that separately (the Forgejo repo is a mirror, not an auto-push
# remote). Keep credentials in your shell/environment, never in this repo.
set -euo pipefail
VERSION="${1:?usage: publish-forgejo.sh <version> [apk]}"
APK="${2:-android/app/build/outputs/apk/release/app-release.apk}"
TAG="${FORGEJO_TAG:-v$VERSION}"
BASE="${FORGEJO_URL:-https://git.skate-spots.org}"
OWNER="${FORGEJO_OWNER:-skatespots}"
REPO="${FORGEJO_REPO:-SkateSpots}"
API="$BASE/api/v1/repos/$OWNER/$REPO"
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
[ -f "$APK" ] || { echo "APK not found: $APK" >&2; exit 1; }
AUTH=()
if [ -n "${FORGEJO_TOKEN:-}" ]; then
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
elif [ -n "${FORGEJO_PASSWORD:-}" ]; then
AUTH=(-u "${FORGEJO_USER:-skatespots}:$FORGEJO_PASSWORD")
else
echo "set FORGEJO_TOKEN (preferred) or FORGEJO_PASSWORD" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
sha="$(sha256sum "$APK" | awk '{print $1}')"
body="$tmpdir/release.md"
cat >"$body" <<EOF
## SkateSpots $VERSION
Signed Android release. SHA-256: \`$sha\`
- Source: $BASE/$OWNER/$REPO
- APK signing key: same as the Zapstore release.
EOF
python3 - "$TAG" "$VERSION" "$body" >"$tmpdir/payload.json" <<'PY'
import json
import sys
from pathlib import Path
tag, version, body_path = sys.argv[1:]
print(json.dumps({
"tag_name": tag,
"name": version,
"body": Path(body_path).read_text(),
"draft": False,
"prerelease": False,
}))
PY
create_code="$(curl -sS -o "$tmpdir/release.json" -w '%{http_code}' \
"${AUTH[@]}" -H 'Content-Type: application/json' \
-X POST "$API/releases" --data-binary @"$tmpdir/payload.json" || true)"
if [ "$create_code" = "201" ]; then
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
elif [ "$create_code" = "409" ] || [ "$create_code" = "422" ]; then
curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" -o "$tmpdir/release.json"
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
else
echo "Forgejo release creation failed (HTTP $create_code)" >&2
cat "$tmpdir/release.json" >&2
exit 1
fi
sha_file="$tmpdir/SkateSpots-$VERSION.apk.sha256"
printf '%s SkateSpots-%s.apk\n' "$sha" "$VERSION" >"$sha_file"
existing_assets="$(python3 - "$tmpdir/release.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
print("\n".join(asset["name"] for asset in data.get("assets", [])))
PY
)"
for file in "$APK" "$sha_file"; do
name="$(basename "$file")"
if printf '%s\n' "$existing_assets" | grep -Fqx "$name"; then
echo "already present: $name"
continue
fi
code="$(curl -sS -o "$tmpdir/asset.json" -w '%{http_code}' \
"${AUTH[@]}" -X POST "$API/releases/$release_id/assets?name=$name" \
-F "attachment=@$file" || true)"
case "$code" in
201|202) echo "uploaded $name" ;;
*) echo "warning: upload of $name returned HTTP $code" >&2 ;;
esac
done
echo "release: $BASE/$OWNER/$REPO/releases/tag/$TAG"
echo "latest: $BASE/$OWNER/$REPO/releases/latest"

220
scripts/quota-proxy.mjs Normal file
View file

@ -0,0 +1,220 @@
#!/usr/bin/env node
/**
* SkateSpots Blossom upload quota proxy.
*
* Caddy sends ONLY `PUT /upload` here; reads and all other Blossom routes go
* straight to Newlay. The proxy verifies the signed BUD-11 (kind 24242) token,
* enforces image-only + size, per-npub daily budgets and a generous per-IP
* backstop, then forwards the accepted bytes to Newlay.
*
* Primary quota key = npub (portable, fair across devices/IPs).
* Secondary key = client IP (anti-abuse / key-rotation backstop).
* Admin pubkeys get a higher npub budget, never an unlimited bypass.
*
* This is intentionally dependency-light: it reuses the nostr-tools already
* installed by the push service, so the relay host needs no new npm install.
*
* Environment:
* PORT=8790
* NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/
* QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json
* ADMIN_PUBKEYS=20e7...,caba...
* MAX_IMAGE_BYTES=10485760
* DAILY_BYTES=262144000 (250 MiB)
* DAILY_UPLOADS=50
* ADMIN_MULTIPLIER=3
* IP_HOURLY_BYTES=524288000 (500 MiB)
* IP_HOURLY_UPLOADS=100
* TOTAL_BYTES=21474836480 (20 GiB)
*/
import http from 'node:http';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(process.env.NOSTR_TOOLS_ROOT || '/home/ubuntu/skatespots-push/');
const { verifyEvent } = require('nostr-tools');
const PORT = Number(process.env.PORT || 8790);
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT || 7777);
const MAX_IMAGE_BYTES = Number(process.env.MAX_IMAGE_BYTES || 10 * 1024 * 1024);
const DAILY_BYTES = Number(process.env.DAILY_BYTES || 250 * 1024 * 1024);
const DAILY_UPLOADS = Number(process.env.DAILY_UPLOADS || 50);
const ADMIN_MULTIPLIER = Number(process.env.ADMIN_MULTIPLIER || 3);
const IP_HOURLY_BYTES = Number(process.env.IP_HOURLY_BYTES || 500 * 1024 * 1024);
const IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100);
const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024);
const STATE_PATH = process.env.QUOTA_STATE || '/home/ubuntu/skatespots-quota/usage.json';
const STATUS_PATH = process.env.STATUS_PATH || '/home/ubuntu/skatespots-quota/status.json';
const ADMIN_PUBKEYS = new Set(
String(process.env.ADMIN_PUBKEYS || '')
.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean),
);
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/webp', 'image/avif',
'image/heic', 'image/heif', 'image/gif',
]);
mkdirSync(dirname(STATE_PATH), { recursive: true });
let state = loadState();
function loadState() {
try {
return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
} catch {
return { day: '', hour: '', totalBytes: 0, users: {}, ips: {} };
}
}
function saveState() {
const tmp = `${STATE_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(state));
renameSync(tmp, STATE_PATH);
}
function rollWindows() {
const now = new Date();
const day = now.toISOString().slice(0, 10);
const hour = now.toISOString().slice(0, 13);
if (state.day !== day) { state.day = day; state.users = {}; }
if (state.hour !== hour) { state.hour = hour; state.ips = {}; }
}
function clientIp(req) {
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return xff || req.socket.remoteAddress || 'unknown';
}
function json(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload),
'access-control-allow-origin': '*',
});
res.end(payload);
}
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const declared = Number(req.headers['content-length'] || 0);
if (declared > limit) { reject(Object.assign(new Error('too large'), { code: 413 })); return; }
const chunks = [];
let total = 0;
req.on('data', (chunk) => {
total += chunk.length;
if (total > limit) {
reject(Object.assign(new Error('too large'), { code: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function decodeToken(header) {
const m = /^Nostr\s+(.+)$/i.exec(String(header || ''));
if (!m) return null;
try {
return JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'));
} catch {
return null;
}
}
function tag(event, name) {
return event.tags?.find(([n]) => n === name)?.[1];
}
function checkToken(event, body, contentType) {
if (!event || event.kind !== 24242) return 'Invalid upload authorization.';
if (!verifyEvent(event)) return 'Invalid upload signature.';
if (tag(event, 't') !== 'upload') return 'Invalid upload token type.';
const exp = Number(tag(event, 'expiration') || 0);
if (!exp || exp < Math.floor(Date.now() / 1000)) return 'Upload authorization expired.';
if (!contentType.startsWith('image/')) return 'Only images can be uploaded.';
const x = String(tag(event, 'x') || '').toLowerCase();
const size = Number(tag(event, 'size') || 0);
const digest = createHash('sha256').update(body).digest('hex');
if (!x || x !== digest || size !== body.length) return 'Upload token does not match the image.';
return null;
}
function checkQuota(pubkey, bytes, ip) {
const admin = ADMIN_PUBKEYS.has(pubkey);
const multiplier = admin ? ADMIN_MULTIPLIER : 1;
const user = state.users[pubkey] || (state.users[pubkey] = { uploads: 0, bytes: 0 });
if (user.uploads + 1 > DAILY_UPLOADS * multiplier) return 'Daily upload count reached. Try again tomorrow.';
if (user.bytes + bytes > DAILY_BYTES * multiplier) return 'Daily upload size reached. Try again tomorrow.';
const ipKey = createHash('sha256').update(ip).digest('hex').slice(0, 24);
const ipRow = state.ips[ipKey] || (state.ips[ipKey] = { uploads: 0, bytes: 0 });
if (ipRow.uploads + 1 > IP_HOURLY_UPLOADS) return 'Too many uploads from this connection. Try again later.';
if (ipRow.bytes + bytes > IP_HOURLY_BYTES) return 'Upload burst limit reached. Try again later.';
if (state.totalBytes + bytes > TOTAL_BYTES) return 'The relay media budget is full. Try again later.';
user.uploads += 1; user.bytes += bytes;
ipRow.uploads += 1; ipRow.bytes += bytes;
state.totalBytes += bytes;
saveState();
return null;
}
function forward(req, res, body) {
const headers = { ...req.headers, host: `127.0.0.1:${UPSTREAM_PORT}` };
if (body) headers['content-length'] = String(body.length);
const upstream = http.request({
hostname: '127.0.0.1', port: UPSTREAM_PORT, path: req.url, method: req.method, headers,
}, (up) => {
res.writeHead(up.statusCode || 502, up.headers);
up.pipe(res);
});
upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' }));
if (body) upstream.end(body); else req.pipe(upstream);
}
function opsAuth(req) {
const event = decodeToken(req.headers.authorization);
if (!event || event.kind !== 27235 || !verifyEvent(event)) return false;
if (!ADMIN_PUBKEYS.has(String(event.pubkey).toLowerCase())) return false;
if (tag(event, 'method') !== 'GET') return false;
if (tag(event, 'u') !== 'https://relay.skate-spots.org/ops') return false;
const exp = Number(tag(event, 'expiration') || 0);
if (exp && exp < Math.floor(Date.now() / 1000)) return false;
return true;
}
const server = http.createServer(async (req, res) => {
if (req.url === '/health') { json(res, 200, { ok: true }); return; }
if (req.url === '/ops') {
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, OPTIONS',
'access-control-allow-headers': 'Authorization, Content-Type',
});
res.end();
return;
}
if (req.method !== 'GET' || !opsAuth(req)) { json(res, 401, { error: 'Admin authorization required.' }); return; }
try {
const raw = readFileSync(STATUS_PATH, 'utf8');
res.writeHead(200, {
'content-type': 'application/json',
'cache-control': 'no-store',
'access-control-allow-origin': '*',
});
res.end(raw);
} catch {
json(res, 503, { error: 'Status snapshot unavailable.' });
}
return;
}
if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; }
const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0];
if (!IMAGE_TYPES.has(contentType)) { json(res, 415, { error: 'Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.' }); return; }
let body;
try { body = await readBody(req, MAX_IMAGE_BYTES); }
catch (err) { json(res, err?.code === 413 ? 413 : 400, { error: err?.code === 413 ? 'Image is too large. Max 10 MB.' : 'Could not read upload.' }); return; }
const event = decodeToken(req.headers.authorization);
const problem = checkToken(event, body, contentType);
if (problem) { json(res, 401, { error: problem }); return; }
rollWindows();
const quota = checkQuota(event.pubkey.toLowerCase(), body.length, clientIp(req));
if (quota) { json(res, 429, { error: quota, 'retry-after': '3600' }); return; }
console.log(`[quota] accepted ${event.pubkey.slice(0, 8)}… ${body.length} bytes ${contentType}`);
forward(req, res, body);
});
server.listen(PORT, '127.0.0.1', () => console.log(`[quota] listening on 127.0.0.1:${PORT}`));

View file

@ -126,6 +126,14 @@ for _ in 1 2 3 4 5 6; do
done
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
if [ -n "${FORGEJO_TOKEN:-}" ] || [ -n "${FORGEJO_PASSWORD:-}" ]; then
log "publishing the APK to the self-hosted Forgejo release page…"
scripts/publish-forgejo.sh "$VERSION" "$APK" || \
log "warning: Forgejo upload failed — the Zapstore release is still valid"
else
log "Forgejo: skipped (set FORGEJO_TOKEN or FORGEJO_PASSWORD to publish there)"
fi
log "committing the release…"
git add package.json package-lock.json release-notes.txt
git commit -m "release: $VERSION" >/dev/null

View file

@ -0,0 +1,10 @@
[Unit]
Description=Back up the SkateSpots Community Cordn state
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/home/sovereignsk8/SkateSpots/scripts/backup-community-state.sh
Nice=10
IOSchedulingClass=idle

View file

@ -0,0 +1,10 @@
[Unit]
Description=Daily SkateSpots Community Cordn state backup
[Timer]
OnCalendar=*-*-* 04:17:00
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,28 @@
[Unit]
Description=SkateSpots Blossom upload quota proxy
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/skatespots-quota
ExecStart=/usr/bin/node /home/ubuntu/skatespots-quota/quota-proxy.mjs
Restart=always
RestartSec=2
Environment=PORT=8790
Environment=NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/
Environment=QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json
Environment=ADMIN_PUBKEYS=20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21,caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b
Environment=MAX_IMAGE_BYTES=10485760
Environment=DAILY_BYTES=262144000
Environment=DAILY_UPLOADS=50
Environment=ADMIN_MULTIPLIER=3
Environment=IP_HOURLY_BYTES=524288000
Environment=IP_HOURLY_UPLOADS=100
Environment=TOTAL_BYTES=21474836480
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -6,10 +6,11 @@ import { BECOME_ROLES } from "./lib/becomeRoles";
import { DeepLinkHandler } from "./components/DeepLinkHandler";
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
import { Layout } from "./components/Layout";
import { RoutePreloader } from "./components/RoutePreloader";
// The map is the homepage, but lazy-loading it keeps its chunk (plus the
// IndexedDB offline-cache layer) out of the initial entry bundle.
const MapPage = lazyWithRetry(() => import("./pages/MapPage"));
// HomePage resolves to a lazy map on web (smaller first entry) and to the
// eager MapPage via a Capacitor-only Vite alias on Android.
import HomePage from "@/pages/HomePage";
// Lazy load everything else — each page becomes its own JS chunk
const Index = lazyWithRetry(() => import("./pages/Index"));
@ -22,6 +23,7 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
const OpsPage = lazyWithRetry(() => import("./pages/OpsPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@ -99,6 +101,7 @@ export function AppRouter() {
return (
<HashRouter>
<DeepLinkHandler />
<RoutePreloader />
<ScrollToTop />
{/* Per-route boundary: a crash in one page shows a recoverable message
instead of blanking the whole app. Resets automatically on navigate
@ -111,9 +114,9 @@ export function AppRouter() {
navigation. Pages no longer wrap themselves in <Layout>. */}
<Route element={<Layout />}>
{/* Map is the homepage */}
<Route path="/" element={<MapPage />} />
<Route path="/" element={<HomePage />} />
{/* /map redirects to / */}
<Route path="/map" element={<MapPage />} />
<Route path="/map" element={<HomePage />} />
{/* Legacy home page still accessible */}
<Route path="/home" element={<Index />} />
<Route path="/events" element={<EventsPage />} />
@ -157,6 +160,7 @@ export function AppRouter() {
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
<Route path="/review" element={<AdminReviewPage />} />
<Route path="/ops" element={<OpsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-shop" element={<CreateShopPage />} />
@ -192,6 +196,8 @@ export function AppRouter() {
<Route path="/license" element={<LicensePage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/supporters" element={<SupportersPage />} />
{/* Friendly alias: people naturally type /support. */}
<Route path="/support" element={<Navigate to="/supporters" replace />} />
<Route path="/donate" element={<DonatePage />} />
{/* Marketplace */}
<Route path="/marketplace-listings" element={<MarketplacePage />} />

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { useMemo } from 'react';
import { ShieldAlert, ArrowRight } from 'lucide-react';
import { ShieldAlert, ArrowRight, Activity } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { isAdminPubkey } from '@/lib/admins';
@ -24,6 +24,7 @@ export function AdminReviewCard() {
if (!isAdmin) return null;
return (
<div className="space-y-2">
<Link
to="/review"
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
@ -41,5 +42,19 @@ export function AdminReviewCard() {
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
</div>
</Link>
<Link
to="/ops"
className="block rounded-2xl border border-cyan-500/20 bg-white/[0.02] transition-colors hover:border-cyan-500/40"
>
<div className="flex items-center gap-3 px-4 py-3.5">
<Activity className="h-5 w-5 shrink-0 text-cyan-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-cyan-400">Operator status</p>
<p className="mt-0.5 text-xs text-muted-foreground">Read-only relay, Cordn, storage and TLS health.</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
</div>
</Link>
</div>
);
}

View file

@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useRelayStatus } from '@/hooks/useRelayStatus';
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
import { useTranslation } from '@/contexts/language';
@ -27,6 +28,7 @@ import {
SKATESPOTS_COORDINATOR,
} from '@/lib/coordinator';
import { cn } from '@/lib/utils';
import { urlDomain } from '@/lib/sanitizeUrl';
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
const s = Math.floor(Date.now() / 1000 - unixSeconds);
@ -59,6 +61,28 @@ async function copyText(text: string, copiedText: string, failedTitle: string, f
}
}
/** Small live health pill for one relay. Probes only while this card is open. */
function RelayHealthPill({ url }: { url: string }) {
const { status, reason, recheck } = useRelayStatus(url);
const dot =
status === 'connected'
? 'bg-green-400'
: status === 'connecting'
? 'bg-blue-400 animate-pulse'
: 'bg-red-400';
return (
<button
type="button"
onClick={recheck}
title={reason ?? status}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-2 py-1 text-[10px] text-gray-400 transition-colors hover:border-white/20 hover:text-gray-200"
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', dot)} />
<span className="truncate">{urlDomain(url)}</span>
</button>
);
}
/**
* Settings → Coordinator.
*
@ -119,6 +143,11 @@ export function CoordinatorSettingsCard() {
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
</p>
{coordinator.relays.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{coordinator.relays.map((url) => <RelayHealthPill key={url} url={url} />)}
</div>
)}
</div>
<span
className={cn(

View file

@ -28,6 +28,7 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { FormWizard, WizardStep } from '@/components/FormWizard';
import { isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
@ -327,7 +328,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
// Craft-specific extras
for (const f of extraFields) {

View file

@ -23,6 +23,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
@ -571,7 +572,7 @@ export function CrewForm({ existing }: CrewFormProps) {
if (s.trim()) tags.push(['social', s.trim()]);
}
for (const handle of nip05s) tags.push(['nip05', handle]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
// Members: admins get an `admin` role marker on their `p` tag.
for (const pk of members) {

View file

@ -7,10 +7,10 @@ import {
DEFAULT_TAB_IDS,
type NavTab,
} from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
import { MapPin, MoreHorizontal, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
interface CustomizeNavSheetProps {
open: boolean;
@ -20,14 +20,14 @@ interface CustomizeNavSheetProps {
/**
* Bottom-sheet editor for the mobile nav bar.
*
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right).
* Layout model: Map (fixed left) · 3 user-picked · More (fixed right).
* The user toggles up to 3 destinations from the catalog and can reorder them.
* Draft state is local until "Done", so changes feel deliberate and cancelable.
*/
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
const { user } = useCurrentUser();
const { t } = useTranslation();
const { user } = useCurrentUser();
// Local draft so we can cancel without persisting.
const [draft, setDraft] = useState<string[]>(middleIds);
@ -115,8 +115,8 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
}
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})}
{/* Fixed Profile */}
<PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" />
{/* Fixed More */}
<PreviewSlot icon={MoreHorizontal} label={t('nav.more')} locked accent="#ff0040" />
</div>
</div>

View file

@ -1002,10 +1002,10 @@ export function DeckCostCalculator() {
a.click();
};
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand).
// Multi-sheet XLSX workbook (lazy — our tiny local OOXML writer is only
// loaded when the user asks for the spreadsheet).
const exportXlsx = async () => {
const XLSX = await import('xlsx');
const wb = XLSX.utils.book_new();
const { downloadXlsx } = await import('@/lib/xlsx');
const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [
[t('legacyMiscUi.exportTitle')],
@ -1024,24 +1024,29 @@ export function DeckCostCalculator() {
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), t('legacyMiscUi.exportXlsxSummary'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
const equipment: (string | number)[][] = [
[t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
]), t('legacyMiscUi.exportSectionEquipment'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
];
const materials: (string | number)[][] = [
[t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
]), t('legacyMiscUi.exportSectionMaterials'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
];
const processes: (string | number)[][] = [
[t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]),
]), t('legacyMiscUi.exportLabelProcesses'));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
];
const packaging: (string | number)[][] = [
[t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
]), t('legacyMiscUi.exportSectionPackaging'));
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`);
];
downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [
{ name: t('legacyMiscUi.exportXlsxSummary'), rows: summary },
{ name: t('legacyMiscUi.exportSectionEquipment'), rows: equipment },
{ name: t('legacyMiscUi.exportSectionMaterials'), rows: materials },
{ name: t('legacyMiscUi.exportLabelProcesses'), rows: processes },
{ name: t('legacyMiscUi.exportSectionPackaging'), rows: packaging },
]);
};
const copySummary = async () => {

View file

@ -20,7 +20,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react';
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, Handshake, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -30,6 +30,7 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import { useTranslation } from '@/contexts/language';
export const EditProfileForm: React.FC = () => {
@ -52,7 +53,7 @@ export const EditProfileForm: React.FC = () => {
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
// form fields, so we manage it here and merge it on save.
const [lud16, setLud16] = useState('');
const lud16Trimmed = lud16.trim();
const lud16Trimmed = lud16.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
@ -72,6 +73,8 @@ export const EditProfileForm: React.FC = () => {
// pointer formats as Settings → Coordinator (nprofile / cordn.net link /
// npub / hex) as well as an http(s) URL — this is the discovery field.
const [cordn, setCordn] = useState('');
const [openToSponsorships, setOpenToSponsorships] = useState(false);
const [sponsorNote, setSponsorNote] = useState('');
// Extra personal NIP-05 handles — stored in a separate kind-30078 record so
// another client re-saving the profile can't drop them.
@ -131,6 +134,8 @@ export const EditProfileForm: React.FC = () => {
);
}
if (typeof raw.cordn === 'string') setCordn(raw.cordn);
setOpenToSponsorships(raw.sponsorships_open === true);
if (typeof raw.sponsorships_note === 'string') setSponsorNote(raw.sponsorships_note);
}
}, [metadata, form]);
@ -220,7 +225,7 @@ export const EditProfileForm: React.FC = () => {
// Merge the custom CLINK offer code (or remove it when cleared).
if (nofferValid) {
data.noffer = nofferTrimmed.toLowerCase();
data.noffer = normalizeClinkCode(nofferTrimmed);
} else {
delete data.noffer;
}
@ -252,6 +257,17 @@ export const EditProfileForm: React.FC = () => {
// Retire the legacy field now that badges supersede it.
delete data.sponsors;
// Sponsorship opt-in (custom kind-0 fields). The profile only claims
// this when the skater explicitly turned it on.
if (openToSponsorships) {
data.sponsorships_open = true;
if (sponsorNote.trim()) data.sponsorships_note = sponsorNote.trim();
else delete data.sponsorships_note;
} else {
delete data.sponsorships_open;
delete data.sponsorships_note;
}
// Cordn contact link (custom field).
if (cordnTrimmed) {
// Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical
@ -402,6 +418,41 @@ export const EditProfileForm: React.FC = () => {
)}
/>
{/* Sponsorship opt-in — explicit consent, never inferred from activity. */}
<div className="space-y-3 rounded-xl border border-pink-500/20 bg-pink-500/[0.04] p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<Label htmlFor="profile-open-sponsorships" className="flex items-center gap-2 text-sm font-semibold">
<Handshake className="h-4 w-4 text-pink-400" />
{t('settingsUi.editProfile.openToSponsorships')}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t('settingsUi.editProfile.openToSponsorshipsHint')}
</p>
</div>
<Switch
id="profile-open-sponsorships"
checked={openToSponsorships}
onCheckedChange={setOpenToSponsorships}
/>
</div>
{openToSponsorships && (
<div className="space-y-1.5">
<Label htmlFor="profile-sponsor-note" className="text-xs font-bold uppercase tracking-wide text-pink-300">
{t('settingsUi.editProfile.sponsorNote')}
</Label>
<Textarea
id="profile-sponsor-note"
value={sponsorNote}
onChange={(event) => setSponsorNote(event.target.value)}
placeholder={t('settingsUi.editProfile.sponsorNotePlaceholder')}
maxLength={180}
rows={2}
/>
</div>
)}
</div>
{/* NIP-05 — the verified-handle field. Given real prominence + live
verification feedback so people know if it's actually working. */}
<FormField
@ -443,6 +494,7 @@ export const EditProfileForm: React.FC = () => {
value={lud16}
onChange={setLud16}
placeholder="you@yourdomain.com"
inputMode="email"
status={
lud16Invalid
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
@ -663,7 +715,8 @@ const FieldRow: React.FC<{
onChange: (v: string) => void;
placeholder: string;
status: { kind: 'hint' | 'ok' | 'error'; text: string };
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => (
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status, inputMode }) => (
<div className="space-y-2">
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
@ -676,6 +729,7 @@ const FieldRow: React.FC<{
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
inputMode={inputMode}
className="font-mono text-sm"
/>
{status.kind === 'error' ? (

View file

@ -1,5 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ZapDialog } from '@/components/ZapDialog';
import { LazyZapDialog } from '@/components/LazyZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { cn } from '@/lib/utils';
@ -64,7 +64,7 @@ export function FundingGoalCard({
)}
</p>
<ZapDialog target={event}>
<LazyZapDialog target={event}>
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
@ -72,7 +72,7 @@ export function FundingGoalCard({
<Zap className="h-4 w-4" />
{reached ? 'Keep supporting' : 'Fund this event'}
</button>
</ZapDialog>
</LazyZapDialog>
</CardContent>
</Card>
);

View file

@ -10,7 +10,8 @@ import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering' | 'clothing';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering' | 'clothing'
| 'transport';
/** Translate the Lab page's ServiceType into a crew focus scene (or null). */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null {
@ -20,6 +21,7 @@ function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed
case 'judging':
case 'organizing':
case 'barbering':
case 'transport':
return null;
case 'art':
case 'graffiti':

View file

@ -23,7 +23,7 @@ export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProp
const { data: allProfiles } = useLabProfiles(200);
const typeLabel = (type: string) => {
const known = ['coach', 'filmer', 'editor', 'photographer', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', 'barber', 'organizer'];
const known = ['coach', 'filmer', 'editor', 'photographer', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', 'barber', 'organizer', 'transport'];
return known.includes(type) ? t(`labUi.labLink.${type}`) : t('labUi.labLink.lab');
};

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { isNativePlatform } from '@/lib/platform';
@ -39,7 +39,6 @@ import {
User,
Plus,
CheckCircle2,
Database,
Package,
Heart,
CreditCard,
@ -52,6 +51,8 @@ import {
MessagesSquare,
ChevronLeft,
ChevronRight,
ChevronDown,
ArrowLeft,
Search,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
@ -111,6 +112,30 @@ function hasCashuWalletHint(): boolean {
}
}
/**
* Page-level Suspense fallback. Keeps the header and bottom nav mounted while a
* lazy route chunk loads, so tapping a menu item never blanks the whole app.
* The fade is delayed slightly: a fast chunk resolves before it becomes visible.
*/
function RouteLoadingFallback() {
return (
<div className="container mx-auto max-w-5xl px-4 py-6" aria-hidden="true">
<div style={{ animation: 'pageFallbackIn 180ms ease 120ms both' }}>
<div className="animate-pulse motion-reduce:animate-none space-y-5">
<div className="h-8 w-48 rounded-lg bg-white/[0.07]" />
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
</div>
</div>
</div>
</div>
);
}
interface LayoutProps {
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
* there are no children. */
@ -159,6 +184,7 @@ function NavLink({
<Link
to={href}
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
isActive
@ -195,8 +221,12 @@ function MobileHeaderUserMenu({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
<div className="flex items-center gap-0.5">
<Link
to={`/${nip19.npubEncode(pubkey)}`}
aria-label={t('chromeUi.viewMyProfile')}
className="touch-manipulation relative flex items-center gap-1.5 rounded-l-full rounded-r-md border border-green-500/30 bg-green-500/10 px-1.5 py-1.5 transition-transform active:scale-95"
>
<Avatar className="h-7 w-7 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
@ -205,14 +235,19 @@ function MobileHeaderUserMenu({
</Avatar>
{/* Status indicator */}
<UserStatusIndicator pubkey={pubkey} size="sm" />
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
{t('chromeUi.inBadge')}
</span>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-black bg-green-500 xs:hidden" />
</Link>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('chromeUi.menu')}
className="flex h-9 w-5 items-center justify-center rounded-r-full text-green-400/80 transition-colors hover:text-green-300 active:scale-95"
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
{/* User info header */}
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
@ -248,12 +283,6 @@ function MobileHeaderUserMenu({
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
<span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
<Plus className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
@ -266,36 +295,6 @@ function MobileHeaderUserMenu({
{t('nav.settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
<Database className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
<Wallet className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">{t('nav.wallet')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">{t('nav.marketplace')}</span>
</Link>
</DropdownMenuItem>
{/* ── Accounts: switch between logged-in npubs + add another ── */}
<DropdownMenuSeparator />
@ -342,21 +341,17 @@ function MobileHeaderUserMenu({
function MobileNav({
isActive,
isLoggedIn,
userPubkey,
userAvatar,
userName,
t,
onCustomize,
onMore,
fixedMapTab,
middleTabs,
}: {
isActive: (path: string) => boolean;
isLoggedIn: boolean;
userPubkey?: string;
userAvatar?: string;
userName?: string;
t: (key: string) => string;
onCustomize: () => void;
onMore: () => void;
fixedMapTab: NavTab;
middleTabs: NavTab[];
}) {
@ -399,6 +394,7 @@ function MobileNav({
<Link
key={key}
to={tab.href}
aria-current={active ? 'page' : undefined}
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
@ -457,60 +453,21 @@ function MobileNav({
{/* CUSTOM: 3 middle tabs */}
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
{/* FIXED: Profile / Login tab.
Logged in → your public profile (View Profile).
Logged out → Settings (login lives there).
Long-press / right-click still opens nav customization. */}
{(() => {
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings';
const tabActive = isActive(profileHref);
return (
<Link
to={profileHref}
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
onContextMenu={(e) => {
e.preventDefault();
onCustomize();
}}
className={cn(
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
)}
{/* FIXED: More — the burger menu owns profile, login, wallet,
settings and every secondary section. */}
<button
type="button"
onClick={onMore}
className="group relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-1 px-1 py-1.5 active:scale-90 transition-transform duration-150 text-gray-500"
aria-label={t('nav.more')}
>
<span
className={cn(
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
)}
>
{isLoggedIn && userPubkey ? (
<div className="relative">
<Avatar className={cn(
'h-[24px] w-[24px] border-2 transition-transform duration-300',
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
)}>
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
{(userName || '??').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
</div>
) : (
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
)}
<span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
<MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
</span>
<span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
tabActive ? 'font-extrabold' : 'font-semibold',
)}>
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
<span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
{t('nav.more')}
</span>
</Link>
);
})()}
</button>
</div>
</div>
</nav>
@ -519,11 +476,22 @@ function MobileNav({
export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation();
const navigate = useNavigate();
const showMobileBack = location.pathname !== '/' && location.pathname !== '/map';
// The map is full-bleed (it manages its own height), so it gets no bottom
// padding even though it shares this one persistent layout with every route.
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
// The floating assistant must not cover form controls on long-form pages.
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileLanguageView, setMobileLanguageView] = useState(false);
// Back/forward, notification taps and global search can change routes without
// clicking a menu row. Never leave the burger sheet covering the new page.
useEffect(() => {
setMobileMenuOpen(false);
setMobileLanguageView(false);
}, [location.pathname]);
// One auth dialog for the header + burger menu. Owning it here (not inside
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
@ -583,7 +551,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
const userAvatar = author.data?.metadata?.picture;
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
const {
primaryTabs: desktopPrimaryTabs,
@ -618,7 +585,11 @@ export function Layout({ children, noPadding }: LayoutProps) {
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8">
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
logo never gets squeezed into (or glowed over by) the nav. */}
<Link to="/" className="flex items-center gap-3 group shrink-0">
<Link
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-3 group shrink-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<img
src="/images/icon-1024.png"
alt="SkateSpots"
@ -837,13 +808,34 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
<div className="flex items-center justify-between h-14 px-3 gap-3">
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
{showMobileBack && (
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-9 w-9 shrink-0 text-gray-300 hover:bg-white/5 hover:text-white"
aria-label={t('common.back')}
title={t('common.back')}
onClick={() => {
if (location.key !== 'default') navigate(-1);
else navigate('/');
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Link
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
aria-label={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
/>
<span className="min-w-0 truncate font-display text-xl neon-red tracking-wide" style={{
<span className={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span>
</Link>
@ -1181,7 +1173,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
tabIndex={-1}
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
>
<Suspense fallback={<RouteLoadingFallback />}>
{children ?? <Outlet />}
</Suspense>
</main>
{/* Footer - Desktop */}
@ -1246,10 +1240,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Bottom Navigation */}
<MobileNav
isActive={isActive}
onMore={() => setMobileMenuOpen(true)}
isLoggedIn={!!user}
userPubkey={user?.pubkey}
userAvatar={userAvatar}
userName={displayName}
t={t}
onCustomize={() => setShowCustomizeNav(true)}
fixedMapTab={fixedMapTab}
@ -1262,12 +1254,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Suspense>
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
{!hideSasquatch && (
<Suspense fallback={null}>
<LazySasquatchGuide
message={currentMessage}
onDismiss={dismissCurrentMessage}
/>
</Suspense>
)}
{/* Set Status Dialog — lazy (on-demand modal) */}
<Suspense fallback={null}>

View file

@ -0,0 +1,72 @@
import {
cloneElement,
isValidElement,
lazy,
Suspense,
useState,
type MouseEvent,
type ReactElement,
type ReactNode,
} from 'react';
import type { Event } from 'nostr-tools';
/**
* Lightweight zap trigger.
*
* The full ZapDialog pulls in the Cashu wallet stack (`useCashu`, mint repos,
* token operations) — several hundred KB that used to land in the initial
* bundle because every SpotCard rendered a ZapButton. This wrapper keeps only
* the trigger in the entry chunk and loads the actual dialog on first tap.
*/
const LoadedZapDialog = lazy(() =>
import('@/components/ZapDialog').then((m) => ({ default: m.ZapDialog })),
);
interface LazyZapDialogProps {
target: Event;
children: ReactNode;
className?: string;
}
export function LazyZapDialog({ target, children, className }: LazyZapDialogProps) {
const [open, setOpen] = useState(false);
const handleClick = (event: MouseEvent) => {
if (isValidElement(children)) {
const child = children as ReactElement<{ onClick?: (event: MouseEvent) => void }>;
child.props.onClick?.(event);
}
setOpen(true);
};
const trigger = isValidElement(children) ? (
cloneElement(
children as ReactElement<{ onClick?: (event: MouseEvent) => void; className?: string }>,
{
onClick: handleClick,
className: [
(children as ReactElement<{ className?: string }>).props.className,
'cursor-pointer',
].filter(Boolean).join(' '),
},
)
) : (
<div className={className} onClick={handleClick}>{children}</div>
);
return (
<>
{trigger}
{open && (
<Suspense fallback={null}>
<LoadedZapDialog
target={target}
open={open}
onOpenChange={setOpen}
hideTrigger
/>
</Suspense>
)}
</>
);
}

View file

@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types';
import { PayWithSats } from '@/components/PayWithSats';
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
@ -183,6 +184,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
</h3>
{isOwner && <PaymentReadinessNote />}
{mine.map((thread) => {
const latest = thread.latest;
const other = latest.author === user.pubkey

View file

@ -1,8 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronUp } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
* The site links that live in the desktop footer — surfaced on the map
@ -14,8 +11,6 @@ import { cn } from '@/lib/utils';
*/
export function MapFooterStrip() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const links = [
{ to: '/diy', label: t('nav.diy') },
{ to: '/newsletter', label: t('nav.newsletter') },
@ -43,26 +38,14 @@ export function MapFooterStrip() {
</div>
</div>
{/* Mobile — a tiny chip that expands to a compact row */}
<div className="pointer-events-auto relative mb-1.5 flex justify-center md:hidden">
{open && (
<div className="absolute bottom-full left-1/2 mb-1.5 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-3 overflow-x-auto whitespace-nowrap rounded-full border border-white/10 bg-black/85 px-3 py-1.5 text-[10px] text-gray-300 backdrop-blur scrollbar-hide">
{/* Mobile — a slim always-visible strip, not a floating chip over the nav */}
<div className="pointer-events-auto flex w-full items-center justify-center gap-3 border-t border-white/10 bg-black/80 px-3 py-1 text-[10px] text-gray-400 backdrop-blur md:hidden">
{links.map((l) => (
<Link key={l.to} to={l.to} className="font-medium hover:text-[#ccff00]">
<Link key={l.to} to={l.to} className="font-semibold transition-colors hover:text-[#ccff00]">
{l.label}
</Link>
))}
</div>
)}
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex min-h-0 items-center gap-1 rounded-full bg-black/45 px-2.5 py-[3px] text-[10px] font-semibold text-gray-400/90 backdrop-blur transition-colors hover:text-gray-200"
>
<ChevronUp className={cn('h-3 w-3 transition-transform', open && 'rotate-180')} />
{t('mapUi.mapFooter.links')}
</button>
</div>
</div>
);
}

View file

@ -58,7 +58,7 @@ export function PaymentReadinessNote() {
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
<span>
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
<Link to="/settings?section=profile" className="text-yellow-400 underline hover:text-yellow-300">
{t('marketUi.paymentReadiness.nostrProfile')}
</Link>{' '}
{t('marketUi.paymentReadiness.notReadyAfter')}

View file

@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { isNativePlatform } from '@/lib/platform';
/**
* Hover / touch prefetch for the primary navigation destinations.
*
* The route chunks stay lazy (so first paint is not blocked), but once the user
* shows intent — hovers a menu row, tabs into a link, or touches it — the page
* module starts downloading. On mobile the touchstart head start is often enough
* to avoid the page skeleton entirely. Only modules are fetched; no page hooks
* mount, so this never triggers relay work or an Amber prompt.
*/
const loaders: Record<string, () => Promise<unknown>> = {
'/lab': () => import('@/pages/LabPage'),
'/events': () => import('@/pages/EventsPage'),
'/crews': () => import('@/pages/CrewsPage'),
'/community': () => import('@/pages/CommunityPage'),
'/skaters': () => import('@/pages/SkatersPage'),
'/messages': () => import('@/pages/DirectMessagesPage'),
'/settings': () => import('@/pages/SettingsPage'),
'/wallet': () => import('@/pages/WalletPage'),
'/stash': () => import('@/pages/StashPage'),
'/marketplace': () => import('@/pages/MarketplaceHubPage'),
'/stats': () => import('@/pages/CommunityStatsPage'),
'/collections': () => import('@/pages/SpotCollectionsPage'),
'/glossary': () => import('@/pages/GlossaryPage'),
'/about': () => import('@/pages/AboutPage'),
'/card': () => import('@/pages/RiderCardPage'),
'/supporters': () => import('@/pages/SupportersPage'),
'/newsletter': () => import('@/pages/NewsletterPage'),
};
const preloaded = new Set<string>();
function routePath(anchor: HTMLAnchorElement): string | null {
const href = anchor.getAttribute('href') ?? '';
if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:')) {
return null;
}
// HashRouter renders to="#/lab" as href="#/lab".
if (href.startsWith('#/')) return href.slice(1).split('?')[0] || '/';
try {
const url = new URL(anchor.href, window.location.href);
if (url.hash.startsWith('#/')) return url.hash.slice(1).split('?')[0] || '/';
return url.pathname;
} catch {
return null;
}
}
function preloadFromTarget(target: EventTarget | null): void {
if (!(target instanceof Element)) return;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
const path = routePath(anchor);
if (!path || preloaded.has(path)) return;
const loader = loaders[path];
if (!loader) return;
preloaded.add(path);
void loader().catch(() => {
// A failed prefetch is harmless; lazyWithRetry will retry on navigation.
preloaded.delete(path);
});
}
export function RoutePreloader() {
useEffect(() => {
if (isNativePlatform()) return;
const onIntent = (event: Event) => preloadFromTarget(event.target);
document.addEventListener('pointerover', onIntent, { capture: true, passive: true });
document.addEventListener('focusin', onIntent, { capture: true, passive: true });
document.addEventListener('touchstart', onIntent, { capture: true, passive: true });
return () => {
document.removeEventListener('pointerover', onIntent, true);
document.removeEventListener('focusin', onIntent, true);
document.removeEventListener('touchstart', onIntent, true);
};
}, []);
return null;
}

View file

@ -23,11 +23,24 @@ import { useTranslation } from '@/contexts/language';
*/
const SESSION_KEY = 'splash-shown-session';
const MIN_MS = 900; // choreography needs room to breathe
const WARM_GRACE_MS = 550; // wait after connection for content to stream in
const MAX_MS = 2600; // hard cap — never block on a slow/offline network
const FADE_MS = 480;
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
const MIN_MS = 380; // keep the brand beat, lose the artificial wait
const WARM_GRACE_MS = 180; // a short breath after relays connect
const MAX_MS = 900; // hard cap — reveal the shell even on a dead network
const FADE_MS = 280; // iris-close exit
const READY_HOLD_MS = 120; // show the ready check, then move
const SPARKS = [
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '20ms' },
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '40ms' },
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '60ms' },
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '80ms' },
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '100ms' },
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '120ms' },
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '140ms' },
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '160ms' },
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '180ms' },
];
const STATUS_KEYS = [
'bootUi.statusWaking',
@ -62,7 +75,7 @@ export function SplashScreen() {
if (!visible || ready) return;
const id = setInterval(() => {
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
}, 640);
}, 380);
return () => clearInterval(id);
}, [visible, ready]);
@ -71,8 +84,8 @@ export function SplashScreen() {
useEffect(() => {
if (!visible || ready) return;
const id = setInterval(() => {
setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045)));
}, 90);
setProgress((p) => (p >= 85 ? p : p + Math.max(0.9, (85 - p) * 0.065)));
}, 60);
return () => clearInterval(id);
}, [visible, ready]);
@ -142,9 +155,24 @@ export function SplashScreen() {
<div
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
style={{
opacity: leaving ? 0 : 1,
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
transition: `opacity ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1), transform ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
// The exit is an aperture-style iris closing into the logo. It reveals
// the already-painted app from the edges without moving the map. Users
// who prefer reduced motion get a simple, quick fade instead.
opacity: leaving && prefersReducedMotion ? 0 : 1,
clipPath: prefersReducedMotion
? undefined
: leaving
? 'circle(0px at 50% 42%)'
: 'circle(150vmax at 50% 42%)',
WebkitClipPath: prefersReducedMotion
? undefined
: leaving
? 'circle(0px at 50% 42%)'
: 'circle(150vmax at 50% 42%)',
transition: prefersReducedMotion
? `opacity ${FADE_MS}ms ease`
: `clip-path ${FADE_MS}ms cubic-bezier(0.7,0,0.84,0)`,
willChange: 'clip-path',
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
paddingLeft: 'env(safe-area-inset-left, 0px)',
@ -171,35 +199,77 @@ export function SplashScreen() {
className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[110px]"
style={{
background:
'radial-gradient(circle, rgba(255,0,64,0.30) 0%, rgba(204,255,0,0.07) 46%, transparent 70%)',
'radial-gradient(circle, rgba(255,106,0,0.32) 0%, rgba(255,0,64,0.08) 46%, transparent 70%)',
...anim('splashGlow 3.4s ease-in-out infinite'),
}}
/>
<div className="relative z-10 flex flex-col items-center px-8">
{/* Logo in a glowing conic halo ring */}
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
{/* Rotating conic ring */}
<div
className="absolute -inset-3 rounded-[26px]"
className="relative z-10 flex flex-col items-center px-8"
style={{
// Keep the mark visible all the way through the iris-close exit.
// The shrinking aperture clips it away, so there is never a dead
// black frame between the splash and the app underneath.
opacity: 1,
transform: leaving && !prefersReducedMotion ? 'translateY(-4px)' : 'translateY(0)',
transition: 'transform 280ms cubic-bezier(0.22,1,0.36,1)',
}}
>
{/* The mark arrives from the native splash's centre and settles into the
column. No rotation: a quick, grounded drop with one landing burst. */}
<div style={anim('splashLogoHandoff 440ms cubic-bezier(0.22,1,0.36,1) both')}>
<div className="relative">
{/* Static conic halo — colour and breathing glow, no spin */}
<div
className="absolute -inset-3 rounded-full"
style={{
background:
'conic-gradient(from 0deg, transparent 0deg, #ff0040 90deg, #ccff00 180deg, transparent 300deg)',
opacity: ready ? 0.9 : 0.55,
filter: 'blur(6px)',
transition: 'opacity 400ms ease',
...anim('splashSpin 3.2s linear infinite'),
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
opacity: ready ? 0.9 : 0.58,
filter: 'blur(7px)',
transition: 'opacity 240ms ease',
}}
aria-hidden="true"
/>
{/* Mask ring interior so only a thin halo shows */}
<div className="absolute -inset-1 rounded-[22px] bg-[#050505]" aria-hidden="true" />
<div style={anim('splashPulse 2.2s ease-in-out infinite')} className="relative">
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
{/* One-shot landing sparks, disabled for reduced-motion users. */}
{!prefersReducedMotion && (
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
{SPARKS.map((spark, index) => (
<span
key={index}
className="splash-spark"
style={{
'--sx': spark.sx,
'--sy': spark.sy,
'--ex': spark.ex,
'--ey': spark.ey,
animationDelay: spark.delay,
} as React.CSSProperties}
/>
))}
</div>
)}
<div
style={anim('splashLogoDrop 380ms cubic-bezier(0.22,1,0.36,1) both')}
className="relative"
>
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="relative h-24 w-24 rounded-[20px] shadow-[0_0_50px_rgba(255,0,64,0.55)]"
draggable={false}
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
/>
{/* Contact shadow / dust line under the wheel */}
<div
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
style={anim('splashDust 1.9s ease-in-out infinite')}
aria-hidden="true"
/>
</div>
</div>
</div>
@ -208,8 +278,8 @@ export function SplashScreen() {
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
style={{
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '120ms',
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '0ms',
}}
>
SKATESPOTS
@ -219,8 +289,8 @@ export function SplashScreen() {
<div
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
style={{
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '220ms',
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '30ms',
}}
role="progressbar"
aria-valuemin={0}
@ -231,7 +301,7 @@ export function SplashScreen() {
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
style={{
width: `${progress}%`,
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
transition: 'width 240ms cubic-bezier(0.4,0,0.2,1)',
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
}}
>
@ -253,8 +323,8 @@ export function SplashScreen() {
<div
className="mt-5 flex h-4 items-center gap-2"
style={{
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '320ms',
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
animationDelay: prefersReducedMotion ? undefined : '60ms',
}}
>
{ready ? (

View file

@ -11,7 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck, Trash2 } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
@ -23,6 +23,7 @@ import { OfficialBadge } from '@/components/OfficialBadge';
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog';
import { MuteMenuItem } from '@/components/MuteMenuItem';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types';
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
@ -30,6 +31,7 @@ import { spotTypeLabel } from '@/lib/spotTypeLabel';
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
interface SpotCardProps {
@ -48,10 +50,13 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
const displayName = metadata?.name ?? genUserName(spot.pubkey);
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Determine if this is a shop or a spot
const isShop = spot.types.includes('shop');
const isOwner = user?.pubkey === spot.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
const canDelete = !!user && (isOwner || isAdmin);
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
// Shops that actually issue access passes get a badge (one shared, cached query).
@ -245,7 +250,19 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuContent align="end" className="w-44">
{canDelete && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowDeleteDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="mr-2 h-4 w-4" />
{isOwner ? t('common.delete') : t('spotUi.removeAdmin')}
</DropdownMenuItem>
)}
{!isOwner && (
<DropdownMenuItem
onClick={(e) => {
@ -275,6 +292,17 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
/>
)}
{/* Owner/admin delete confirmation */}
{canDelete && (
<DeleteEntityDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
event={spot.event}
entityLabel={isShop ? t('mapUi.skateShop') : t('spotUi.spot')}
entityName={spot.title}
/>
)}
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}

View file

@ -1,11 +1,20 @@
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
* "Ways to support" — three purpose-first cards; each scrolls to its rail by id
* (#support-lightning / #support-silent / #support-angor), so it works anywhere
* those anchors exist (/supporters and /donate).
*/
export function WaysToSupport({ className }: { className?: string }) {
export function WaysToSupport({
className,
active,
onSelect,
}: {
className?: string;
active?: string;
onSelect?: (id: string) => void;
}) {
const { t } = useTranslation();
const ways = [
{ id: 'support-lightning', emoji: '⚡', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') },
@ -13,16 +22,29 @@ export function WaysToSupport({ className }: { className?: string }) {
{ id: 'support-angor', emoji: '⛓', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') },
];
return (
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-3 ${className ?? ''}`}>
<div className={`grid grid-cols-3 gap-2 ${className ?? ''}`}>
{ways.map((w) => (
<button
key={w.id}
onClick={() => document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
className="min-h-0 min-w-0 rounded-xl border border-white/10 bg-black/60 p-4 text-left transition-colors hover:border-[#ccff00]/40 hover:bg-white/5"
type="button"
aria-pressed={active === w.id}
onClick={() => {
if (onSelect) {
onSelect(w.id);
return;
}
document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
className={cn(
'min-h-0 min-w-0 rounded-xl border p-2.5 text-left transition-colors sm:p-3',
active === w.id
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
: 'border-white/10 bg-black/60 hover:border-[#ccff00]/40 hover:bg-white/5',
)}
>
<span className="text-2xl">{w.emoji}</span>
<p className="mt-2 text-sm font-black uppercase text-white">{w.title}</p>
<p className="mt-1 text-xs text-gray-400">{w.desc}</p>
<span className="text-xl">{w.emoji}</span>
<p className="mt-1 text-[10px] font-black uppercase leading-tight text-white sm:mt-2 sm:text-xs">{w.title}</p>
<p className="mt-0.5 hidden text-[10px] leading-snug text-gray-400 sm:block">{w.desc}</p>
</button>
))}
</div>

View file

@ -1,4 +1,4 @@
import { ZapDialog } from '@/components/ZapDialog';
import { LazyZapDialog } from '@/components/LazyZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -65,7 +65,7 @@ export function ZapButton({
}
return (
<ZapDialog target={target}>
<LazyZapDialog target={target}>
<div className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4" />
<span className="text-xs">
@ -78,6 +78,6 @@ export function ZapButton({
)}
</span>
</div>
</ZapDialog>
</LazyZapDialog>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@ -42,6 +42,11 @@ interface ZapDialogProps {
target: Event;
children?: React.ReactNode;
className?: string;
/** Controlled open state (used by LazyZapDialog). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Skip the built-in trigger when the caller owns the trigger. */
hideTrigger?: boolean;
}
const presetAmounts = [
@ -420,9 +425,21 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
});
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
export function ZapDialog({
target,
children,
className,
open: controlledOpen,
onOpenChange,
hideTrigger = false,
}: ZapDialogProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = useCallback((next: boolean) => {
if (controlledOpen !== undefined) onOpenChange?.(next);
else setInternalOpen(next);
}, [controlledOpen, onOpenChange]);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
@ -664,11 +681,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
shouldScaleBackground={false}
fadeFromIndex={0}
>
{!hideTrigger && children != null && (
<DrawerTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DrawerTrigger>
)}
<DrawerContent
key={invoice ? 'payment' : 'form'}
className={cn(
@ -732,11 +751,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
{!hideTrigger && children != null && (
<DialogTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DialogTrigger>
)}
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg break-words">

View file

@ -7,7 +7,6 @@ import {
CheckCheck,
ChevronDown,
Copy,
ExternalLink,
ImagePlus,
KeyRound,
Loader2,
@ -17,6 +16,7 @@ import {
RefreshCw,
Reply,
ScanLine,
Search,
Send,
Share2,
ShieldMinus,
@ -25,12 +25,25 @@ import {
Trash2,
UserPlus,
Users,
Volume2,
VolumeX,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Dialog,
@ -49,13 +62,15 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { useDmMute } from '@/hooks/useDmMute';
import { useCordnRoomRead } from '@/hooks/useCordnRoomRead';
import { enqueueCordnOutbox, markCordnOutboxAttempt, readCordnOutbox, removeCordnOutbox } from '@/lib/cordnOutbox';
import { shortNpub } from '@/lib/coordinator';
import { isNativePlatform } from '@/lib/platform';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
import type { CordnRoom } from '@/lib/cordn/room';
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
type RoomScope = 'crew' | 'community';
@ -88,6 +103,8 @@ interface PendingMessage {
text: string;
imageUrl?: string;
state: 'sending' | 'failed';
outboxId?: string;
attempts?: number;
}
/**
@ -166,6 +183,9 @@ export function CordnNativePanel({
const sendNative = native.send;
const checkInviteNative = native.checkInvite;
const [draft, setDraft] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [online, setOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine);
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
const [reactingTo, setReactingTo] = useState<string | null>(null);
const readSentRef = useRef<Set<string>>(new Set());
@ -184,6 +204,10 @@ export function CordnNativePanel({
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const { isMuted, toggle: toggleMute } = useDmMute();
const { reads, markActive, markSeen } = useCordnRoomRead(user?.pubkey);
const muteKey = native.groupId ? `cordn:${native.groupId}` : undefined;
const muted = isMuted(muteKey);
const fileRef = useRef<HTMLInputElement>(null);
const checkingRef = useRef(false);
const loadedRequestsRef = useRef(false);
@ -219,6 +243,74 @@ export function CordnNativePanel({
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
}, [user?.pubkey, native.groupId]);
const latestInboundAt = useMemo(
() =>
native.messages.reduce(
(max, message) =>
message.direction === 'inbound' && message.sender !== user?.pubkey
? Math.max(max, message.createdAt)
: max,
0,
),
[native.messages, user?.pubkey],
);
useEffect(() => {
if (native.groupId && latestInboundAt > 0) markActive(native.groupId, latestInboundAt);
}, [native.groupId, latestInboundAt, markActive]);
useEffect(() => {
if (active && native.groupId && latestInboundAt > 0) markSeen(native.groupId, latestInboundAt);
}, [active, native.groupId, latestInboundAt, markSeen]);
useEffect(() => {
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
// A different room must not inherit the previous room's draft/pending bubble.
useEffect(() => {
setPending(null);
setReplyTo(null);
setReactingTo(null);
setSearch('');
setSearchOpen(false);
}, [native.groupId]);
// Restore the oldest failed send after a reload/room switch. It is retried
// automatically a few times when the app is online, then stays queued for a
// deliberate tap.
useEffect(() => {
if (!user?.pubkey || !native.groupId) return;
const [next] = readCordnOutbox(user.pubkey, native.groupId);
if (!next) return;
setPending((current) =>
current ?? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
},
);
}, [user?.pubkey, native.groupId]);
const displayMessages = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return native.messages;
return native.messages.filter(
(message) =>
message.content.toLowerCase().includes(query) ||
(message.imageUrl ?? '').toLowerCase().includes(query),
);
}, [native.messages, search]);
useEffect(() => {
if (native.group && awaiting) {
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
@ -254,36 +346,37 @@ export function CordnNativePanel({
onRequestsChange?.(canManage ? native.requests.length : 0);
}, [canManage, native.requests.length, onRequestsChange]);
// "New since last opened" marker. While the tab is visible we advance the
// cursor; while it is hidden we only compare inbound timestamps. The first
// observation seeds the cursor so a room's old history is not "unread".
const messageCursor = useMemo(() => {
const latest = native.messages
.filter((m) => m.direction === 'inbound' && m.sender !== user?.pubkey)
.reduce((max, m) => Math.max(max, m.createdAt), 0);
return { latest };
}, [native.messages, user?.pubkey]);
// "New since last opened" marker. The panel records the latest inbound
// timestamp; a hidden tab compares it with the local seen cursor. The first
// observation seeds the cursor so old history never reads as unread.
useEffect(() => {
if (!user?.pubkey || !groupGid || !native.ready) {
onUnreadChange?.(false);
return;
}
const gid = groupGid;
const latest = messageCursor.latest;
if (active) {
writeCordnSeen(user.pubkey, gid, latest);
onUnreadChange?.(false);
return;
}
const seen = readCordnSeen(user.pubkey, gid);
if (seen === null) {
writeCordnSeen(user.pubkey, gid, latest);
const state = reads[groupGid];
if (!state) {
markSeen(groupGid, latestInboundAt);
onUnreadChange?.(false);
} else {
onUnreadChange?.(latest > seen);
return;
}
}, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]);
markActive(groupGid, latestInboundAt);
onUnreadChange?.(latestInboundAt > state.seenAt);
}, [
active,
groupGid,
native.ready,
user?.pubkey,
reads,
latestInboundAt,
markActive,
markSeen,
onUnreadChange,
]);
// Opening the private tab pulls anything missed while it was hidden. Silent
// signers also poll in the hook; this makes tab activation immediate.
@ -325,18 +418,53 @@ export function CordnNativePanel({
}, [t]);
const deliver = useCallback(
async (text: string, imageUrl?: string) => {
async (text: string, imageUrl?: string, outboxId?: string): Promise<boolean> => {
setNotice(null);
setPending({ text, imageUrl, state: 'sending' });
const currentAttempt = outboxId
? readCordnOutbox(user?.pubkey, native.groupId).find((item) => item.id === outboxId)?.attempts
: undefined;
setPending({
text,
imageUrl,
state: 'sending',
outboxId,
attempts: currentAttempt,
});
const sent = await sendNative(text, imageUrl);
if (sent) {
markSent(sent.id);
setPending(null);
} else {
setPending((current) => (current ? { ...current, state: 'failed' } : current));
if (outboxId) removeCordnOutbox(user?.pubkey, native.groupId, outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
return true;
}
let nextOutboxId = outboxId;
let attempts = currentAttempt ?? 0;
if (nextOutboxId) {
markCordnOutboxAttempt(user?.pubkey, native.groupId, nextOutboxId);
attempts += 1;
} else {
const item = enqueueCordnOutbox(user?.pubkey, native.groupId, text, imageUrl);
nextOutboxId = item?.id;
attempts = item?.attempts ?? 1;
}
setPending({
text,
imageUrl,
state: 'failed',
outboxId: nextOutboxId,
attempts,
});
return false;
},
[sendNative, markSent],
[sendNative, markSent, user?.pubkey, native.groupId],
);
const handleSend = useCallback(async () => {
@ -344,21 +472,41 @@ export function CordnNativePanel({
if (!body || native.busy) return;
setDraft('');
const content = withReply(body);
const sent = await native.send(content);
if (sent) {
markSent(sent.id);
setReplyTo(null);
setPending(null);
} else {
setPending({ text: content, state: 'failed' });
}
}, [draft, native, withReply, markSent]);
const ok = await deliver(content);
if (ok) setReplyTo(null);
}, [draft, native.busy, withReply, deliver]);
const retrySend = useCallback(async () => {
if (!pending || pending.state !== 'failed') return;
await deliver(pending.text, pending.imageUrl);
await deliver(pending.text, pending.imageUrl, pending.outboxId);
}, [pending, deliver]);
const discardPending = useCallback(() => {
if (pending?.outboxId) removeCordnOutbox(user?.pubkey, native.groupId, pending.outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
}, [pending, user?.pubkey, native.groupId]);
// A failed send is retried a few times when the app is online, then left for
// the explicit Retry button. This avoids both data loss and infinite retry.
useEffect(() => {
if (
!pending ||
pending.state !== 'failed' ||
!online ||
!native.ready ||
(pending.attempts ?? 0) >= 3
) return;
const timer = window.setTimeout(() => void retrySend(), 12_000);
return () => window.clearTimeout(timer);
}, [pending, online, native.ready, retrySend]);
const onPickImage = useCallback(
async (file: File | undefined) => {
if (!file) return;
@ -448,6 +596,18 @@ export function CordnNativePanel({
setAwaiting(true);
}, [native, user?.pubkey]);
const handleApprove = useCallback(async (request: { pk: string; kp_ref: string; at: number }) => {
const ok = await native.approve(
request.kp_ref || request.pk,
request.pk,
{ pk: request.pk, at: request.at },
);
if (ok) {
setNotice(t('cordnUi.requestHandled'));
window.setTimeout(() => setNotice(null), 2500);
}
}, [native, t]);
const handleCheckInvite = useCallback(async () => {
if (native.busy) return;
setNotice(null);
@ -564,6 +724,36 @@ export function CordnNativePanel({
<Lock className="h-4 w-4 text-teal-400" /> {copy.title}
</CardTitle>
<div className="flex flex-wrap items-center justify-end gap-2">
{native.group && (
<>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-gray-400 hover:text-teal-300"
onClick={() => {
setSearchOpen((open) => !open);
if (searchOpen) setSearch('');
}}
aria-label={t('common.search')}
title={t('common.search')}
>
<Search className="h-3.5 w-3.5" />
</Button>
{muteKey && (
<Button
size="icon"
variant="ghost"
className={cn('h-7 w-7 hover:text-amber-300', muted ? 'text-amber-400' : 'text-gray-400')}
onClick={() => toggleMute(muteKey)}
aria-pressed={muted}
aria-label={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
title={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
>
{muted ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
</Button>
)}
</>
)}
{native.group && (
<Badge variant="secondary" className="text-[10px]">
{t('cordnUi.memberCount', { count: native.group.members.length })}
@ -624,6 +814,23 @@ export function CordnNativePanel({
</div>
</div>
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
{native.group && native.lastCheckedAt && (
<p className="pt-0.5 text-[10px] text-gray-600">
{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}
</p>
)}
{!isCommunity && (
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-180" />
{t('cordnUi.compareTitle')}
</summary>
<div className="mt-1.5 space-y-1 text-[11px] leading-relaxed text-gray-500">
<p>{t('cordnUi.compareChat')}</p>
<p>{t('cordnUi.comparePrivate')}</p>
</div>
</details>
)}
</CardHeader>
<CardContent className="space-y-3">
@ -668,20 +875,49 @@ export function CordnNativePanel({
</p>
<ol className="mx-auto max-w-sm space-y-2 text-left">
{steps.map((step, index) => (
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300">
{index + 1}
{steps.map((step, index) => {
// Members see the setup move visibly: device key prepared →
// request sent → waiting for an admin. Founders start at step 1.
const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady);
const stepWaiting = !canCreate && index === 1 && awaiting;
return (
<li
key={step}
className={cn(
'flex items-start gap-2 text-xs',
stepDone || stepWaiting ? 'text-teal-200' : 'text-gray-400',
)}
>
<span className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
stepDone ? 'bg-teal-500/20 text-teal-300' : 'bg-teal-500/15 text-teal-300',
)}>
{stepDone ? <Check className="h-2.5 w-2.5" /> : stepWaiting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : index + 1}
</span>
<span>{step}</span>
</li>
))}
);
})}
</ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
{copy.encryptedNote}
</p>
{/* Amber needs one foreground approval for the device key and one
for the join request. Show the two translated step labels so the
second prompt is expected, not a mystery. */}
{native.interactiveHintVisible && !canCreate && (
<p className="mx-auto flex max-w-sm items-center justify-center gap-1.5 text-[11px] text-amber-300/80">
<KeyRound className="h-3 w-3 shrink-0" />
<span>Amber</span>
<span aria-hidden="true">·</span>
<span>{t('cordnUi.setupDevice')}</span>
<span aria-hidden="true">→</span>
<span>{t('cordnUi.requestJoin')}</span>
</p>
)}
<div className="flex flex-wrap items-center justify-center gap-2">
{canCreate ? (
<Button
@ -771,6 +1007,7 @@ export function CordnNativePanel({
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady}
lastCheckedAt={native.lastCheckedAt}
copied={copied}
onCopyRef={() => void copyRef()}
/>
@ -799,7 +1036,6 @@ export function CordnNativePanel({
onToggleAdmin={() => void updateAdminRole(member.pubkey, roleOf(member.pubkey) !== 'admin')}
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
onRemove={() => {
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
void native.removeMember(member.leafIndex).then((ok) => {
if (!ok) setNotice(t('cordnUi.removeMemberFailed'));
});
@ -826,16 +1062,41 @@ export function CordnNativePanel({
</div>
</details>
{searchOpen && (
<div className="flex items-center gap-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
className="h-8 border-white/10 bg-black/40 text-xs"
autoFocus
/>
{search && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400"
onClick={() => setSearch('')}
aria-label={t('common.clear')}
title={t('common.clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{native.messages.length === 0 && !pending ? (
{displayMessages.length === 0 && !pending ? (
<p className="py-6 text-center text-xs text-gray-500">
{t('cordnUi.placeholder')}
{search.trim() ? t('common.noResults') : t('cordnUi.placeholder')}
</p>
) : (
native.messages.map((m) => (
displayMessages.map((m) => (
<div
key={m.id}
className={cn('flex flex-col gap-0.5', m.direction === 'outbound' && 'items-end')}
@ -971,6 +1232,9 @@ export function CordnNativePanel({
{pending.state === 'sending'
? t('cordnUi.sending')
: t('cordnUi.sendFailed')}
{pending.state === 'failed' && pending.outboxId && (
<span className="ml-1 text-gray-500">· {t('cordnUi.queued')}</span>
)}
</span>
</div>
<div className="max-w-[85%] space-y-1 overflow-hidden rounded-2xl bg-teal-500/20 px-3 py-1.5 text-sm text-teal-100 opacity-80">
@ -999,7 +1263,7 @@ export function CordnNativePanel({
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-gray-400"
onClick={() => setPending(null)}
onClick={discardPending}
>
<X className="h-3 w-3" /> {t('cordnUi.dismiss')}
</Button>
@ -1094,6 +1358,7 @@ export function CordnNativePanel({
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady || !!native.group}
lastCheckedAt={native.lastCheckedAt}
copied={copied}
onCopyRef={() => void copyRef()}
/>
@ -1129,7 +1394,7 @@ export function CordnNativePanel({
key={`${req.pk}-${req.kp_ref}`}
pubkey={req.pk}
disabled={native.busy}
onApprove={() => void native.approve(req.kp_ref || req.pk, req.pk, { pk: req.pk, at: req.at })}
onApprove={() => void handleApprove(req)}
botPubkey={botPubkey}
botName={copy.botName}
/>
@ -1241,12 +1506,14 @@ function AdvancedPanel({
groupRef,
coordinatorPubkey,
keyPackageReady,
lastCheckedAt,
copied,
onCopyRef,
}: {
groupRef: string | null;
coordinatorPubkey: string;
keyPackageReady: boolean;
lastCheckedAt: number | null;
copied: boolean;
onCopyRef: () => void;
}) {
@ -1283,6 +1550,14 @@ function AdvancedPanel({
{keyPackageReady ? t('cordnUi.keyPackageReady') : t('cordnUi.keyPackageMissing')}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.lastChecked')}</span>
<span className="text-gray-400">
{lastCheckedAt
? formatDistanceToNow(new Date(lastCheckedAt), { addSuffix: true })
: '—'}
</span>
</div>
{groupRef && (
<>
<div className="flex items-center gap-2 text-[11px] text-gray-500">
@ -1305,15 +1580,9 @@ function AdvancedPanel({
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href={cordnUrl ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-teal-300 transition-colors hover:text-teal-200"
>
<ExternalLink className="h-3 w-3" />{' '}
{t('socialUi.crewCoordinator.openCordnGroup')}
</a>
<span className="w-full text-[10px] text-gray-600">
{t('cordnUi.cordnWebCaveat')}
</span>
<Button
size="sm"
variant="ghost"
@ -1325,7 +1594,7 @@ function AdvancedPanel({
) : (
<Copy className="h-3 w-3" />
)}
{copiedCordn ? t('cordnUi.copied') : t('cordnUi.copyRef')}
{copiedCordn ? t('cordnUi.copied') : t('cordnUi.copyCordnLink')}
</Button>
</div>
</>
@ -1438,16 +1707,31 @@ function MemberRow({
</Button>
)}
{canRemove && onRemove && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
onClick={onRemove}
aria-label={t('common.remove')}
title={t('common.remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.remove')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cordnUi.removeMemberConfirm', { name: shortNpub(pubkey) })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onRemove}>{t('common.remove')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
@ -1471,14 +1755,29 @@ function RequestRow({
return (
<div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} botPubkey={botPubkey} botName={botName} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
disabled={disabled}
onClick={onApprove}
className="h-7 shrink-0 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('cordnUi.approveTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cordnUi.approveDesc', { name: shortNpub(pubkey) })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onApprove}>{t('cordnUi.approve')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,7 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users } from 'lucide-react';
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users, QrCode } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useToast } from '@/hooks/useToast';
@ -217,6 +219,7 @@ export function CrewCoordinatorCard({
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
const { t } = useTranslation();
const { toast } = useToast();
const [showQr, setShowQr] = useState(false);
const nprofile = coordinator.pubkey
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
: null;
@ -247,6 +250,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
</a>
{nprofile && (
<>
<button
type="button"
onClick={() => void copyNprofile()}
@ -254,6 +258,23 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
>
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
</button>
<button
type="button"
onClick={() => setShowQr((v) => !v)}
aria-expanded={showQr}
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
>
<QrCode className="h-3.5 w-3.5" /> QR
</button>
</>
)}
{showQr && nprofile && (
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-xl border border-white/10 bg-black/40 p-3">
<QRCodeCanvas value={nprofile} size={168} level="M" />
<p className="text-center text-[11px] text-gray-500">
{t('socialUi.coordinator.scanToJoin')}
</p>
</div>
)}
</>
);

View file

@ -99,7 +99,7 @@ export interface EventResult {
export type LabRole =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge'
| 'organizer' | 'barber';
| 'organizer' | 'barber' | 'transport';
export interface CoachResult {
role: LabRole;
@ -562,6 +562,7 @@ export function useCVMClient() {
builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009,
trainer: 35010, musician: 35011, judge: 35012, organizer: 35013,
barber: 35014,
transport: 35016,
};
const KIND_ROLE: Record<number, LabRole> = Object.fromEntries(
Object.entries(ROLE_KIND).map(([role, kind]) => [kind, role as LabRole])
@ -607,7 +608,7 @@ export function useCVMClient() {
const SELF_TAGS = new Set([
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
'skateorganizer', 'skatebarber',
'skateorganizer', 'skatebarber', 'skatetransport',
]);
const result: CoachResult[] = profiles.map((e) => {

View file

@ -58,11 +58,13 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams)
staleTime: interactive ? Infinity : 2000,
// Amber: decrypt the backlog once per session, not on every crew-page visit.
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
queryFn: async (): Promise<CordnMessage[]> => {
queryFn: async (c): Promise<CordnMessage[]> => {
if (!endpoint || !user) return [];
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
const signer = user.signer as unknown as Nip44Signer;
const signal = AbortSignal.timeout(10000);
// Honour query cancellation too — the fetch loop decrypts per message and
// must not keep opening Amber after the crew page is closed.
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
return fetchGroupMessages({
relay,
endpoint,

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -25,6 +25,11 @@ export function useCordnJoinNotifications(opened = false) {
const interactive = isInteractiveSigner(user, logins);
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
// The bell can close while a decrypt loop is running; React Query stops
// scheduling new fetches when `enabled` flips, but does not necessarily abort
// the current queryFn. Read the live open state inside the loop.
const openedRef = useRef(opened);
openedRef.current = opened;
// Amber prompts once per decrypt. Load these only when the bell is opened;
// passive signers can fetch them quietly in the background.
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
@ -51,6 +56,9 @@ export function useCordnJoinNotifications(opened = false) {
);
const notices: CordnJoinNotice[] = [];
for (const event of events) {
// Closing the bell/unmounting the query must stop further Amber
// decrypt prompts; the old loop kept going after navigation.
if (!openedRef.current || c.signal.aborted) break;
let plain: string;
try {
plain = event.content.trim().startsWith('{')

View file

@ -71,7 +71,7 @@ export interface UseCordnNativeResult {
/** Send a read receipt for an inbound message. */
markRead: (messageId: string) => Promise<boolean>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
refresh: () => Promise<boolean>;
/** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<boolean>;
/** Non-admin: ask the coordinator to be added (publishes a key package). */
@ -103,11 +103,15 @@ export interface UseCordnNativeResult {
groupRef: string | null;
/** True when this device is set up: it holds the group or a published key package. */
keyPackageReady: boolean;
/** Last completed coordinator/session check, or null before the first one. */
lastCheckedAt: number | null;
/** Re-open the session after a connection error (the Retry button). */
retry: () => void;
}
const POLL_MS = 6000;
const HIDDEN_POLL_MS = 12000;
const MAX_BACKOFF_MS = 60000;
/**
* Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn
@ -151,6 +155,7 @@ export function useCordnNative({
const [error, setError] = useState<string | null>(null);
const [groupRef, setGroupRef] = useState<string | null>(null);
const [keyPackageReady, setKeyPackageReady] = useState(false);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const interactive = isInteractiveSigner(user, logins);
@ -209,9 +214,13 @@ export function useCordnNative({
opened.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
setReady(true);
setLastCheckedAt(Date.now());
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLastCheckedAt(Date.now());
}
});
return () => {
cancelled = true;
@ -224,30 +233,66 @@ export function useCordnNative({
};
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
const refresh = useCallback(async () => {
const refresh = useCallback(async (): Promise<boolean> => {
const session = sessionRef.current;
if (!session || !group) return;
if (!session || !group) return false;
try {
await session.fetch(group.gid);
const next = session.messages(group.gid);
setMessages(next);
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
setError(null);
setLastCheckedAt(Date.now());
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setLastCheckedAt(Date.now());
return false;
}
}, [group]);
// Live polling for silent signers only — an interactive signer would pop for
// every request, so those users refresh/send manually (same rule as the DM
// and sealed-relay chat hooks).
// and sealed-relay chat hooks). Hidden tabs poll less, and a failure backs
// off exponentially with jitter so a busy/rate-limited coordinator is not
// hammered by every open device.
const pollDelayRef = useRef(POLL_MS);
useEffect(() => {
if (!ready || !group || interactive || !canBackgroundPoll) return;
const id = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(id);
}, [ready, group, interactive, canBackgroundPoll, refresh]);
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
const tick = async () => {
const ok = await refresh();
if (cancelled) return;
if (ok) {
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
} else {
pollDelayRef.current = Math.min(
MAX_BACKOFF_MS,
Math.max(POLL_MS, Math.round(pollDelayRef.current * 1.7)),
);
}
const jitter = Math.round(Math.random() * 800);
timer = setTimeout(() => void tick(), pollDelayRef.current + jitter);
};
timer = setTimeout(() => void tick(), pollDelayRef.current);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [ready, group, interactive, canBackgroundPoll, active, refresh]);
// One coordinator operation at a time. With Amber every coordinator request
// is a foreground approval; a double-tap on "Set up" used to start two
// publish/join flows, which looked like Amber randomly opening twice and left
// one of the two requests to time out.
const runInFlight = useRef(false);
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
if (runInFlight.current) return undefined;
runInFlight.current = true;
setBusy(true);
setError(null);
try {
@ -256,7 +301,9 @@ export function useCordnNative({
setError(err instanceof Error ? err.message : String(err));
return undefined;
} finally {
runInFlight.current = false;
setBusy(false);
setLastCheckedAt(Date.now());
}
}, []);
@ -549,6 +596,7 @@ export function useCordnNative({
passiveSigner,
groupRef,
keyPackageReady,
lastCheckedAt,
retry,
};
}

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import {
cordnRoomReadEventName,
markCordnRoomActive,
markCordnRoomSeen,
readCordnRoomReads,
type CordnRoomReadMap,
} from '@/lib/cordnRoomRead';
/**
* Reactive view of {@link readCordnRoomReads}. Safe to mount on list pages:
* it only reads localStorage and listens for same-tab/cross-tab updates.
*/
export function useCordnRoomRead(pubkey: string | undefined) {
const [reads, setReads] = useState<CordnRoomReadMap>(() => readCordnRoomReads(pubkey));
useEffect(() => {
setReads(readCordnRoomReads(pubkey));
const onChange = (event: Event) => {
const detail = (event as CustomEvent<{ pubkey?: string; reads?: CordnRoomReadMap }>).detail;
if (!detail || detail.pubkey !== pubkey || !detail.reads) return;
setReads(detail.reads);
};
window.addEventListener(cordnRoomReadEventName(), onChange);
return () => window.removeEventListener(cordnRoomReadEventName(), onChange);
}, [pubkey]);
const isUnread = useCallback(
(gid: string | undefined) => {
if (!gid) return false;
const state = reads[gid];
return !!state && state.latestAt > state.seenAt;
},
[reads],
);
const markActive = useCallback(
(gid: string, latestAt: number) => markCordnRoomActive(pubkey, gid, latestAt),
[pubkey],
);
const markSeen = useCallback(
(gid: string, latestAt: number) => markCordnRoomSeen(pubkey, gid, latestAt),
[pubkey],
);
return { reads, isUnread, markActive, markSeen };
}

View file

@ -245,6 +245,21 @@ type DecryptedDm = CachedDm;
const DM_DECRYPT_CACHE_MAX = 4000;
const dmDecryptCache = new Map<string, DecryptedDm>();
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
/**
* Seal plaintext cache. A gift wrap needs TWO decrypts (seal, then rumor). If
* the user navigates while Amber is open after the first one, the query aborts
* before we can build the full message. Keeping the seal means the next visit
* only asks Amber for the rumor instead of prompting twice for the same wrap.
*/
const dmSealCache = new Map<string, string>();
function rememberSeal(key: string, sealJson: string): void {
if (dmSealCache.size >= DM_DECRYPT_CACHE_MAX) {
const oldest = dmSealCache.keys().next().value;
if (oldest !== undefined) dmSealCache.delete(oldest);
}
dmSealCache.set(key, sealJson);
}
// Which accounts' persisted caches have been loaded into memory this session,
// and any hydration in flight (so concurrent readers await the same load).
@ -286,11 +301,17 @@ async function decryptGiftWrap(
viewerPk: string,
signer: DmSigner,
wrap: NostrEvent,
signal?: AbortSignal,
): Promise<DecryptedDm | null> {
// A cancelled query must stop launching NEW Amber intents. Without this, a
// user who starts decrypting the inbox and then navigates away would keep
// getting Amber popups for every remaining gift wrap in the background.
if (signal?.aborted) return null;
const key = `${viewerPk}:${wrap.id}`;
// Hydrate the persisted cache once per account before the first lookup, so a
// relaunch reuses decryptions that already happened on this device.
await ensureDmCacheHydrated(viewerPk);
if (signal?.aborted) return null;
const cached = dmDecryptCache.get(key);
if (cached) return cached;
const flying = dmDecryptInFlight.get(key);
@ -298,7 +319,15 @@ async function decryptGiftWrap(
const task = (async (): Promise<DecryptedDm | null> => {
try {
const sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content);
let sealJson = dmSealCache.get(key);
if (!sealJson) {
sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content);
rememberSeal(key, sealJson);
}
// Navigation may have cancelled the query while Amber was open. Do not
// ask for the second decrypt (which would pop Amber again) — the next
// explicit visit reuses the cached seal and only asks for the rumor.
if (signal?.aborted) return null;
const seal = JSON.parse(sealJson) as NostrEvent;
if (seal.kind !== SEAL_KIND) return null;
// SECURITY: verify the seal's signature (sender impersonation).
@ -326,6 +355,7 @@ async function decryptGiftWrap(
if (oldest !== undefined) dmDecryptCache.delete(oldest);
}
dmDecryptCache.set(key, dm);
dmSealCache.delete(key);
// Persist so this wrap is never decrypted again (fire-and-forget).
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
return dm;
@ -661,10 +691,11 @@ export function useConversation(otherPubkey: string) {
const messages: DirectMessage[] = [];
for (const giftWrap of uniqueEvents) {
if (c.signal.aborted) break;
try {
// Decrypt via the shared session cache (coalesced; one decrypt per wrap
// per session even across different views).
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
if (!dm) continue;
// 1:1 only. A message with more than one recipient is a GROUP message
@ -750,9 +781,10 @@ export function useConversations() {
let undecryptable = 0;
for (const giftWrap of uniqueEvents) {
if (c.signal.aborted) break;
try {
// Decrypt via the shared session cache.
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
if (!dm) continue;
// 1:1 list only. A message with more than one recipient is a GROUP
@ -864,8 +896,9 @@ export function useGroupConversations() {
const byGroup = new Map<string, DirectMessage[]>();
for (const giftWrap of uniqueEvents) {
if (c.signal.aborted) break;
try {
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
if (!dm) continue;
// 1:1 messages are surfaced by useConversations, not here.
if (dm.recipients.length <= 1) continue;
@ -975,8 +1008,9 @@ export function useGroupConversation(participants: string[]) {
const messages: DirectMessage[] = [];
for (const giftWrap of uniqueEvents) {
if (c.signal.aborted) break;
try {
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
if (!dm) continue;
// Only include if this message is part of the target group.

View file

@ -56,7 +56,7 @@ export function useFollowers(pubkey?: string) {
// Query all kind 3 events that have a p tag with our pubkey
const events = await nostr.query(
[{ kinds: [3], '#p': [targetPubkey], limit: 100 }],
[{ kinds: [3], '#p': [targetPubkey], limit: 500 }],
{ signal }
);
@ -119,6 +119,7 @@ export function useFollowActions() {
onSuccess: (_targetPubkey) => {
// Invalidate queries to refetch
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['followers', _targetPubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({
@ -165,9 +166,10 @@ export function useFollowActions() {
return targetPubkey;
},
onSuccess: () => {
onSuccess: (targetPubkey) => {
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['followers', targetPubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({

View file

@ -17,7 +17,7 @@ import {
SKATE_MUSICIAN_KIND,
SKATE_JUDGE_KIND,
SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
SKATE_BARBER_KIND, SKATE_TRANSPORT_KIND, SKATE_DESIGNER_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
MARKETPLACE_ITEM_KIND,
@ -79,6 +79,8 @@ export interface GrowthStats {
totalJudges: number;
totalOrganizers: number;
totalBarbers: number;
totalTransport: number;
totalDesigners: number;
totalTours: number;
totalCrews: number;
/** All unique pubkeys that have published any content */
@ -230,6 +232,8 @@ export function useGrowthStats() { // Community stats read from the CANONICAL c
judgeEvents,
organizerEvents,
barberEvents,
transportEvents,
designerEvents,
listingEvents,
tradeEvents,
] = await Promise.all([
@ -253,6 +257,8 @@ export function useGrowthStats() { // Community stats read from the CANONICAL c
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_BARBER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TRANSPORT_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_DESIGNER_KIND], limit: 500 }], { signal }).catch(() => []),
// Marketplace: live listings + completed-trade attestations.
querySkateContent([{ kinds: [MARKETPLACE_ITEM_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [TRADE_ATTESTATION_KIND], limit: 1000 }], { signal }).catch(() => []),
@ -295,8 +301,10 @@ export function useGrowthStats() { // Community stats read from the CANONICAL c
const judges = dedup(judgeEvents);
const organizers = dedup(organizerEvents);
const barbers = dedup(barberEvents);
const transport = dedup(transportEvents);
const designers = dedup(designerEvents);
// Aggregate of the "other" crafts for active-mapper / total rollups.
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers];
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers, ...transport, ...designers];
// ── Marketplace ──────────────────────────────────────────────────────
// Showcase-only stash items are public but NOT for sale, so exclude them.
@ -574,6 +582,8 @@ export function useGrowthStats() { // Community stats read from the CANONICAL c
totalJudges: judges.length,
totalOrganizers: organizers.length,
totalBarbers: barbers.length,
totalTransport: transport.length,
totalDesigners: designers.length,
totalTours: tours.length,
totalCrews: crews.length,
totalMappers: allPubkeys.size,

View file

@ -2,14 +2,14 @@ import { useQuery } from '@tanstack/react-query';
import {
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND, SKATE_TRANSPORT_KIND,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer,
parseSkateBarber, parseSkateDesigner,
parseSkateBarber, parseSkateDesigner, parseSkateTransport,
isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge, type SkateOrganizer,
type SkateBarber, type SkateDesigner,
type SkateBarber, type SkateDesigner, type SkateTransport,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
@ -18,12 +18,12 @@ import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber' | 'designer';
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber' | 'designer' | 'transport';
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer
| SkateBarber | SkateDesigner
| SkateBarber | SkateDesigner | SkateTransport
) & {
profileType: LabProfileType;
};
@ -31,13 +31,13 @@ export type LabProfile = (
const LAB_KINDS = [
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND, SKATE_TRANSPORT_KIND,
];
const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer',
'skatebarber', 'skateclothing',
'skatebarber', 'skateclothing', 'skatetransport',
];
/** profileType → addressable kind, for building a coordinate. */
@ -46,7 +46,7 @@ const KIND_BY_TYPE: Record<LabProfileType, number> = {
photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND,
graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND,
musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, organizer: SKATE_ORGANIZER_KIND,
barber: SKATE_BARBER_KIND, designer: SKATE_DESIGNER_KIND,
barber: SKATE_BARBER_KIND, designer: SKATE_DESIGNER_KIND, transport: SKATE_TRANSPORT_KIND,
};
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
@ -108,6 +108,10 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
const p = parseSkateDesigner(event);
return p ? { ...p, profileType: 'designer' } : null;
}
case SKATE_TRANSPORT_KIND: {
const p = parseSkateTransport(event);
return p ? { ...p, profileType: 'transport' } : null;
}
default:
return null;
}
@ -212,3 +216,33 @@ export function useLabProfile(identifier: string, pubkey?: string) {
refetchOnMount: 'always',
});
}
/** Lab provider profiles authored by one pubkey (profile-page services strip). */
export function useUserLabProfiles(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['user-lab-profiles', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden),
queryFn: async () => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(12_000);
const events = await querySkateContent(
[{
kinds: LAB_KINDS,
authors: [pubkey],
'#t': LAB_SELF_TAGS,
limit: 50,
}],
{ signal },
);
return events
.map(parseLabProfile)
.filter((profile): profile is LabProfile => !!profile)
.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 60_000,
});
}

View file

@ -23,8 +23,8 @@ import { usePreferences } from '@/hooks/usePreferences';
* Bottom-nav customization (mobile).
*
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
* FIXED Profile/Login slot on the right. The user customizes the **3 middle
* slots** from a curated catalog. Anything they don't pin stays reachable in the
* FIXED More button on the right. The user customizes the **3 middle slots**
* from a curated catalog. Profile/login lives in the header avatar menu. Anything they don't pin stays reachable in the
* burger menu, so nothing is ever lost — just re-prioritized.
*
* Stored per-device in localStorage (v1). Could later sync across devices via a
@ -55,7 +55,7 @@ export const FIXED_MAP_TAB: NavTab = {
/**
* Everything the user can pin to the 3 middle slots.
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.)
* (Map is excluded — it's the fixed anchor. More is the fixed right slot.)
*/
export const NAV_CATALOG: NavTab[] = [
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
@ -75,11 +75,12 @@ export const NAV_CATALOG: NavTab[] = [
];
/** Number of user-customizable middle slots. */
export const MIDDLE_SLOTS = 4;
export const MIDDLE_SLOTS = 3;
/** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable
* from the phone nav out of the box; users can swap any of them via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints'];
/** Default middle tabs. Keep the mobile bar to five intents total:
* fixed Map + these three + a fixed More button. Everything else lives in the
* burger menu, and users can still swap any middle tab via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
@ -90,7 +91,7 @@ const STORAGE_KEY = 'skatespots:nav-tabs:v1';
// ever lost. Map is always the first primary tab and cannot be hidden/moved.
/** Default desktop primary tabs. */
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints'];
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'community'];
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';

View file

@ -105,6 +105,42 @@ export function useUpcomingSkateEvents(limit = 20, options: { enabled?: boolean
});
}
/** Events authored by one pubkey (profile page). Upcoming first, then recent. */
export function useUserEvents(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['user-skate-events', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(12000);
const events = await querySkateContent(
[{
kinds: [CALENDAR_EVENT_KIND],
authors: [pubkey],
'#t': ['skateboarding'],
limit: 50,
}],
{ signal },
);
const now = Math.floor(Date.now() / 1000);
return events
.map(parseSkateEvent)
.filter((event): event is SkateEvent => !!event)
.sort((a, b) => {
const aUpcoming = a.start >= now;
const bUpcoming = b.start >= now;
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
return aUpcoming ? a.start - b.start : b.start - a.start;
});
},
staleTime: 60_000,
});
}
export function useSkateEvent(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();

View file

@ -11,7 +11,76 @@ import { useCurrentUser } from "./useCurrentUser";
* mirror URL on the returned tags so photo redundancy is explicit in the event;
* the read side also derives it from the sha256 (lib/imageFallback).
*/
const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/', 'https://blossom.primal.net/', 'https://blossom.band/'];
// Uploads go only through our own Blossom/quota proxy. The public mirrors are
// still used by the read-side image fallback for older blobs, but including them
// here would let a quota rejection silently fall through to third-party hosting.
const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/'];
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/webp', 'image/avif',
'image/heic', 'image/heif', 'image/gif',
]);
/** Unwrap the quota proxy's JSON error so forms can show a human message. */
function uploadErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const match = /Blossom upload failed \(\d+\):\s*(\{.*\})/.exec(message);
if (match) {
try {
const parsed = JSON.parse(match[1]) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// fall through to the original message
}
}
return message;
}
function validateImage(file: File): void {
const type = (file.type || '').toLowerCase();
if (!IMAGE_TYPES.has(type)) {
throw new Error('Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.');
}
if (file.size > MAX_IMAGE_BYTES) {
throw new Error('Image is too large. The maximum is 10 MB.');
}
}
/**
* Resize/compress camera photos before upload. Most phone photos are far larger
* than the app ever displays; 1600px WebP cuts upload time and Blossom storage
* without touching GIFs or formats the WebView cannot decode.
*/
async function prepareImage(file: File): Promise<File> {
const type = (file.type || '').toLowerCase();
if (type === 'image/gif' || type === 'image/heic' || type === 'image/heif') return file;
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file;
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
// Already small enough: keep the original bytes/format.
if (scale === 1 && file.size <= 1_200_000) {
bitmap.close();
return file;
}
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) { bitmap.close(); return file; }
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.84);
});
if (!blob || blob.size >= file.size) return file;
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
} catch {
return file;
}
}
function hostOf(url: string): string {
try {
@ -30,12 +99,20 @@ export function useUploadFile() {
throw new Error('Must be logged in to upload files');
}
validateImage(file);
const prepared = await prepareImage(file);
const uploader = new BlossomUploader({
servers: UPLOAD_SERVERS,
signer: user.signer,
});
const tags = await uploader.upload(file);
let tags;
try {
tags = await uploader.upload(prepared);
} catch (error) {
throw new Error(uploadErrorMessage(error));
}
// Remember the mirror copy of the same blob. Blossom is content-addressed,
// so `<other-host>/<sha256>` is the same file.

View file

@ -608,6 +608,52 @@ const labOptions = {
recycled: 'Recycled / Deadstock',
},
},
transport: {
vehicle: {
car: 'Car',
taxi: 'Taxi / Rideshare',
van: 'Van / Minibus',
moto: 'Motorcycle',
helicopter: 'Helicopter',
plane: 'Plane',
boat: 'Boat',
other: 'Other vehicle',
},
service: {
people: 'People',
goods: 'Goods / Cargo',
gear: 'Skate gear',
airport: 'Airport transfer',
venue: 'Venue / Event transfer',
tours: 'Tours / Road trips',
logistics: 'Event logistics',
other: 'Other service',
},
feature: {
cargo: 'Cargo space',
bike_board: 'Bike / board rack',
wheelchair: 'Wheelchair access',
pets: 'Pets welcome',
border: 'Cross-border',
night: 'Night driving',
covered: 'Covered / weatherproof',
group: 'Large groups',
},
capacity: {
solo: '1–3 people',
small_group: '4–7 people',
large_group: '8+ people',
van_load: 'Van load',
freight: 'Freight / cargo',
},
coverage: {
local: 'Local',
regional: 'Regional',
national: 'National',
international: 'International',
cross_border: 'Cross-border routes',
},
},
tour: {
skill: {
beginner: { label: 'Beginner', description: 'New to skating or basic tricks' },
@ -1218,6 +1264,52 @@ const labOptions = {
recycled: 'Reciclado / deadstock',
},
},
transport: {
vehicle: {
car: 'Coche',
taxi: 'Taxi / VTC',
van: 'Furgoneta / minibús',
moto: 'Motocicleta',
helicopter: 'Helicóptero',
plane: 'Avión',
boat: 'Barco',
other: 'Otro vehículo',
},
service: {
people: 'Personas',
goods: 'Mercancía / carga',
gear: 'Material de skate',
airport: 'Traslado al aeropuerto',
venue: 'Traslado a evento / local',
tours: 'Rutas / viajes',
logistics: 'Logística de eventos',
other: 'Otro servicio',
},
feature: {
cargo: 'Espacio de carga',
bike_board: 'Portabicis / portatablas',
wheelchair: 'Acceso para silla de ruedas',
pets: 'Mascotas bienvenidas',
border: 'Cruza fronteras',
night: 'Conduce de noche',
covered: 'Cubierto / resistente al clima',
group: 'Grupos grandes',
},
capacity: {
solo: '1–3 personas',
small_group: '4–7 personas',
large_group: '8+ personas',
van_load: 'Carga de furgoneta',
freight: 'Mercancía / carga',
},
coverage: {
local: 'Local',
regional: 'Regional',
national: 'Nacional',
international: 'Internacional',
cross_border: 'Rutas transfronterizas',
},
},
tour: {
skill: {
beginner: { label: 'Principiante', description: 'Nuevo en el skate o trucos básicos' },

View file

@ -918,7 +918,10 @@ const batch1 = {
"noGroupMember": "Encara no ets al xat privat d'aquesta colla.",
"create": "Crea el xat privat",
"requestJoin": "Demana d'unir-t'hi",
"requestAgain": "Request again",
"requestAgain": "Torna a sol·licitar",
"requestHandled": "Sol·licitud gestionada",
"copyCordnLink": "Copia l’enllaç de Cordn",
"cordnWebCaveat": "Cordn web es pot encallar després d’iniciar sessió; unir-te des de l’app és més fiable.",
"checkInvite": "Comprova si hi ha invitació",
"requested": "Sol·licitud enviada: un administrador de la colla l'aprovarà.",
"noInvite": "Encara no hi ha cap invitació. Demana a un administrador que aprovi la teva sol·licitud.",
@ -928,6 +931,8 @@ const batch1 = {
"requests": "Sol·licituds d'accés",
"checkRequests": "Comprova les sol·licituds",
"approve": "Afegeix",
"approveTitle": "Aprovar aquest dispositiu?",
"approveDesc": "Afegir {{name}} a aquesta sala xifrada? Aquest dispositiu podrà llegir els missatges nous.",
"noRequests": "No hi ha sol·licituds pendents.",
"inviteHint": "De moment només hi ets tu en aquest xat: els membres de la colla poden tocar \"Demana d'unir-t'hi\" i després els aproves aquí.",
"shareInvite": "Convida",
@ -959,7 +964,11 @@ const batch1 = {
onboardingMember3: "Ja hi ets: els missatges van xifrats.",
encryptedNote: "Només ho poden llegir els membres de la colla; el coordinador només retransmet missatges xifrats.",
advanced: "Avançat",
compareTitle: "Xat vs xat privat",
compareChat: "El xat és un DM de grup NIP-17 estàndard. Funciona amb altres clients Nostr i no té coordinador.",
comparePrivate: "El xat privat utilitza MLS: control de membres més fort i secret de reenviament. El coordinador veu les metadades del grup i el text xifrat, mai el text dels missatges.",
coordinator: "Coordinador",
lastChecked: "Darrera comprovació",
keyPackage: "Paquet de claus",
keyPackageReady: "A punt en aquest dispositiu",
keyPackageMissing: "No configurat en aquest dispositiu",
@ -983,6 +992,7 @@ const batch1 = {
qrTitle: "Escaneja per unir-te al xat privat",
qrHint: "Els membres de la colla ho escanegen amb la càmera del mòbil. L’enllaç obre la pestanya Xat privat.",
sendFailed: "No s’ha pogut enviar: toca Reintenta.",
queued: "En cua en aquest dispositiu",
sending: "S’està enviant…",
sent: "Enviat",
retry: "Reintenta",
@ -1018,8 +1028,8 @@ const batch1 = {
"noInvite": "Encara no hi ha invitació. El bot normalment aprova en un minut; després toca «Comprova si hi ha invitació».",
"pendingRequests": "{{count}} sol·licituds d'entrada en espera",
"pendingRequestsDesc": "El bot normalment aprova automàticament. Si s'ha encallat, obre la sala de sota i toca Comprova les sol·licituds.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"botMissing": "El bot de la comunitat no és en aquesta sala ara mateix, així que un admin revisarà les sol·licituds d’unió.",
"secondDeviceHint": "Ja ets membre en un altre dispositiu? Fes servir «Enllaça aquest dispositiu» per a un traspàs directe, o torna a demanar d’entrar amb aquest dispositiu.",
"create": "Crea la sala de la comunitat",
"setupDeviceBody": "Això dóna a aquest dispositiu una clau per a la sala de la comunitat. Un administrador o el bot l'aprova una vegada.",
"onboardingFounder1": "Crea la sala de la comunitat.",

View file

@ -395,6 +395,7 @@ const batch2 = {
newMessage: "Missatge nou",
groups: "Grups",
refresh: "Actualitza",
privateRooms: "Sales privades",
crews: "Crews",
crewFounder: "Fundador",
crewMember: "Membre",

View file

@ -353,6 +353,10 @@ const batch3 = {
"bannerImage": "Imatge de portada",
"bannerDesc": "Una imatge ampla per a la part superior del teu perfil.",
"website": "Lloc web",
"openToSponsorships": "Obert a patrocinis",
"openToSponsorshipsHint": "Mostra una targeta de patrocini/col·laboració al teu perfil. No es comparteix res fins que algú t'escriu.",
"sponsorNote": "Nota de patrocini (opcional)",
"sponsorNotePlaceholder": "p. ex. Obert a socis de taules, sabates, viatges i esdeveniments.",
"bitcoinTitle": "Bitcoin i pagaments",
"bitcoinDesc": "Deixa que la gent t'enviï zaps, propines i pagaments directament, sense intermediaris.",
"lightningAddress": "Adreça Lightning",

View file

@ -666,6 +666,8 @@ const batch5 = {
manage: "Gestiona",
admin: "Administració",
editProfile: "Edita el perfil",
transportSelfAttestedTitle: "Perfil de transport autodeclarat",
transportSelfAttested: "SkateSpots no verifica llicències, assegurances, l'aptitud dels vehicles per circular ni permisos d'operació. Fes servir la insígnia de confiança, les ressenyes, els avals i el teu propi criteri.",
publishedTitle: "Ja estàs en línia",
publishedBody: "El teu perfil s'ha publicat a The Lab. Comparteix-lo perquè els skaters et trobin.",
manageRemove: "Elimina el teu perfil de {{noun}} de The Lab.",
@ -1437,6 +1439,38 @@ const batch5 = {
recycled: "Reciclat / deadstock"
}
},
transport: {
vehicle: {
car: "Cotxe",
taxi: "Taxi / VTC",
van: "Furgoneta / minibús",
moto: "Motocicleta",
helicopter: "Helicòpter",
plane: "Avió",
boat: "Vaixell",
other: "Altre vehicle"
},
service: {
people: "Persones",
goods: "Mercaderies / càrrega",
gear: "Material de skate",
airport: "Trasllat a l'aeroport",
venue: "Trasllat a local / esdeveniment",
tours: "Rutes / viatges",
logistics: "Logística d'esdeveniments",
other: "Altre servei"
},
feature: {
cargo: "Espai de càrrega",
bike_board: "Suport per a bici / taula",
wheelchair: "Accés amb cadira de rodes",
pets: "Mascotes benvingudes",
border: "Travessa fronteres",
night: "Conducció nocturna",
covered: "Cobert / resistent al clima",
group: "Grups grans"
}
},
tour: {
skill: {
beginner: {

View file

@ -714,8 +714,9 @@ const batch6 = {
judges: "Jutges",
organizers: "Organitzadors",
barbers: "Barbers",
designers: "Dissenyadors",
showFewer: "Mostra'n menys",
showAllCrafts: "Mostra els 13 oficis",
showAllCrafts: "Mostra els 15 oficis",
community: "Comunitat",
tours: "Rutes",
crews: "Crews",
@ -833,6 +834,28 @@ const batch6 = {
our: "LA NOSTRA",
community: "COMUNITAT",
heroDesc: "SkateSpots està fet per a la comunitat i per la comunitat. Suportadors, col·laboradors, socis i amics que el mantenen gratuït i descentralitzat.",
grantsSection: "Entitats finançadores de Bitcoin",
grantsSectionDesc: "Per a organitzacions que atorguen subvencions a projectes Bitcoin de codi obert.",
grantsTitle: "Oberts a patrocinis · Entitats finançadores de Bitcoin",
grantsDisclaimer: "Vols donar a aquest projecte? Omple el formulari. No per fer-nos KYC a nosaltres — sinó per fer-te KYC a TU.",
grantsDisclaimerDesc: "Aquest canal és només per a entitats finançadores que fan KYC/KYB als seus beneficiaris. Si ets un skater que només vol donar, usa les vies Lightning, Silent Payments o Angor de dalt — ningú no necessita el teu passaport.",
grantsOrg: "Organització",
grantsContact: "Contacte (npub, correu electrònic o nom d'usuari)",
grantsWebsite: "Lloc web",
grantsProgram: "Programa / ronda de subvencions",
grantsSize: "Import de la subvenció",
grantsKyc: "Procés de KYC / KYB",
grantsFund: "Què vols finançar?",
grantsAck: "Confirmo que som una entitat finançadora que fa KYC/KYB als seus beneficiaris.",
grantsSend: "Obrir conversa sobre la subvenció",
grantsNoKycTitle: "Sense KYC de SkateSpots",
grantsNoKycDesc: "Mai no demanem la teva identificació ni la nostra. La via KYC/KYB és per als teus beneficiaris.",
grantsNoTrackingTitle: "Sense seguiment",
grantsNoTrackingDesc: "Sense analítica, CRM ni llistes de correu. Aquesta pàgina no desa res.",
grantsPrivateTitle: "Missatge privat",
grantsPrivateDesc: "Obre una conversa xifrada NIP-17 amb el propietari.",
grantsEthos: "Només acceptem subvencions que mantinguin SkateSpots de codi obert, autoallotjat i sense analítica. Cap condició que tanqui el projecte.",
grantsNote: "Obre un missatge privat al propietari de SkateSpots. No es desa res en aquesta pàgina.",
backAngor: "Dona'ns suport a Angor",
backers: "Suportadors",
backersDesc: "Skaters que han donat suport a SkateSpots — finançament on-chain via Angor i zaps Lightning al projecte.",
@ -962,6 +985,19 @@ const batch6 = {
nip05Verified: "Verificat amb NIP-05",
nip05NotVerified: "NIP-05 no verificat amb aquesta clau",
message: "Missatge",
eventsHosted: "Esdeveniments organitzats",
labServices: "Serveis de The Lab",
openToSponsorships: "Obert a patrocinis",
forSponsors: "Per a patrocinadors i col·laboracions",
sponsorPitch: "Patrocini, col·laboració, foto/vídeo o esdeveniment? Comença una conversa.",
startCollab: "Inicia un xat de col·laboració",
viewRiderCard: "Veure la fitxa del patinador",
collabMessage: "Hola {{name}}, voldria parlar d'un patrocini o d'una col·laboració.",
followers: "Seguidors",
followersHint: "Llistes de seguidors reportades pels relés: no són analítiques auditades.",
following: "Seguint",
follow: "Segueix",
unfollow: "Deixa de seguir",
zapsReceived: "zaps rebuts",
memberSince: "Membre des de",
badges: "Insígnies",

View file

@ -19,6 +19,90 @@ const drift = {
autoTranslateDesc: "Tradueix els missatges entrants al teu idioma. S'executa en aquest dispositiu; no s'envia res.",
privacy: "La traducció s'executa al teu dispositiu. El teu missatge no surt mai d'aquí. El primer ús descarrega un model de llengua.",
},
labUi: {
tabTransport: 'Transport',
filterVehicles: 'Vehicles',
filterTransportServices: 'Serveis',
filterCapabilities: 'Capacitats',
filterCapacity: 'Capacitat',
filterCoverage: 'Cobertura',
offerTransport: 'Oferir transport',
empty: {
transportTitle: 'Encara no hi ha proveïdors de transport',
transportBody: 'Mous skaters, equip o càrrega? Publica el teu cotxe, furgoneta, taxi, bici, helicòpter, avió o vaixell.',
},
labLink: {
transport: 'Transport',
designer: 'Roba',
},
detail: {
noun: {
transport: 'proveïdor de transport',
},
skillsTitle: {
transport: 'Vehicles i serveis',
},
},
craftFields: {
capacity: 'Capacitat',
cargo: 'Espai de càrrega',
coverage: 'Cobertura / rutes',
availability: 'Disponibilitat',
base: 'Zona base',
accessibility: 'Accessibilitat',
},
},
walletUi: {
stats: {
transport: 'Transport',
},
},
becomeRolesUi: {
transport: {
seoTitle: 'Oferir transport / conducció - SkateSpots',
seoDescription: 'Mou skaters, equip i càrrega — cotxes, taxis, furgonetes, motos, helicòpters, avions i vaixells. Autodeclarat, revisat per la comunitat.',
heading: 'Transport / Conductor',
tagline: 'Mou persones i equip per l\'escena — terra, aire o mar',
noun: 'proveïdor de transport',
rateLabel: 'Tarifa / Viatge',
portfolioLabel: 'Fotos del vehicle / de la flota',
sectionVehicles: 'Vehicles',
sectionServices: 'Serveis',
sectionFeatures: 'Què pots oferir',
sectionCapacity: 'Capacitat',
sectionCoverage: 'Cobertura',
capacityLabel: 'Capacitat',
capacityPlaceholder: 'p. ex. 4 passatgers / 2 taules',
cargoLabel: 'Espai de càrrega',
cargoPlaceholder: 'p. ex. furgoneta plena, 8 caixes de taules',
coverageLabel: 'Cobertura / rutes',
coveragePlaceholder: 'p. ex. Barcelona ↔ Perpinyà, regional',
availabilityLabel: 'Disponibilitat',
availabilityPlaceholder: 'p. ex. caps de setmana, dies d\'esdeveniment, a demanda',
baseLabel: 'Zona base',
basePlaceholder: 'p. ex. Barcelona / El Prat',
accessibilityLabel: 'Accessibilitat',
accessibilityPlaceholder: 'p. ex. rampa, espai per a cadira de rodes, assistència',
},
},
labOptions: {
transport: {
capacity: {
solo: '1–3 persones',
small_group: '4–7 persones',
large_group: '8+ persones',
van_load: 'Càrrega de furgoneta',
freight: 'Mercaderies / càrrega',
},
coverage: {
local: 'Local',
regional: 'Regional',
national: 'Nacional',
international: 'Internacional',
cross_border: 'Rutes transfrontereres',
},
},
},
};
export default drift;

View file

@ -349,6 +349,10 @@ const csBatch3 = {
bannerImage: "Obrázek banneru",
bannerDesc: "Široký obrázek pro horní část tvého profilu.",
website: "Web",
openToSponsorships: "Otevřeno sponzorům",
openToSponsorshipsHint: "Na profilu zobrazí kartu pro sponzory a spolupráce. Nic se nesdílí, dokud ti někdo nenapíše.",
sponsorNote: "Poznámka ke sponzoringu (volitelné)",
sponsorNotePlaceholder: "např. Otevřeno partnerům pro prkna, boty, cestování a akce.",
bitcoinTitle: "Bitcoin a platby",
bitcoinDesc: "Nech lidi posílat ti zaps, spropitné a platby přímo — bez prostředníka.",
lightningAddress: "Lightning adresa",

View file

@ -688,8 +688,9 @@ export const csBatch4 = {
judges: "Rozhodčí",
organizers: "Organizátoři",
barbers: "Barbeři",
designers: "Designéři",
showFewer: "Zobrazit méně",
showAllCrafts: "Zobrazit všech 13 řemesel",
showAllCrafts: "Zobrazit všech 15 řemesel",
community: "Komunita",
tours: "Túry",
crews: "Crew",
@ -807,6 +808,28 @@ export const csBatch4 = {
our: "NAŠE",
community: "KOMUNITA",
heroDesc: "SkateSpots je tvořen komunitou a pro komunitu. Podporovatelé, spolupracovníci, partneři a přátelé, kteří drží tohle zdarma a decentralizované.",
grantsSection: "Poskytovatelé grantů na Bitcoin",
grantsSectionDesc: "Pro organizace, které udělují granty otevřeným bitcoinovým projektům.",
grantsTitle: "Otevřeno sponzorství · Poskytovatelé grantů na Bitcoin",
grantsDisclaimer: "Chceš tomuto projektu přispět? Vyplň formulář. Ne abychom prošli KYC my — ale abys prošel KYC TY.",
grantsDisclaimerDesc: "Tato kolonka je jen pro poskytovatele grantů, kteří dělají KYC/KYB svým příjemcům. Pokud jsi skejťák, který chce jen přispět, použij výše uvedené cesty Lightning, Silent Payments nebo Angor — tvůj pas nikdo nepotřebuje.",
grantsOrg: "Organizace",
grantsContact: "Kontakt (npub, e-mail nebo přezdívka)",
grantsWebsite: "Web",
grantsProgram: "Grantový program / kolo",
grantsSize: "Velikost grantu",
grantsKyc: "Proces KYC / KYB",
grantsFund: "Co bys chtěl financovat?",
grantsAck: "Potvrzuji, že jsme poskytovatel grantů, který dělá KYC/KYB svým příjemcům.",
grantsSend: "Zahájit rozhovor o grantu",
grantsNoKycTitle: "Žádné KYC SkateSpots",
grantsNoKycDesc: "Nikdy nechceme tvůj průkaz ani ten náš. Kolonka KYC/KYB je pro tvé příjemce.",
grantsNoTrackingTitle: "Žádné sledování",
grantsNoTrackingDesc: "Žádné analýzy, CRM ani e-mailové seznamy. Tato stránka nic neukládá.",
grantsPrivateTitle: "Soukromá zpráva",
grantsPrivateDesc: "Otevře šifrovanou konverzaci NIP-17 s majitelem.",
grantsEthos: "Přijímáme jen granty, které udrží SkateSpots s otevřeným zdrojovým kódem, vlastním hostováním a bez analýz. Žádné podmínky, které projekt zavřou.",
grantsNote: "Otevře soukromou zprávu majiteli SkateSpots. Na této stránce se nic neukládá.",
backAngor: "Podpoř nás na Angor",
backers: "Podporovatelé",
backersDesc: "Skejťáci, kteří podpořili SkateSpots — on-chain financování přes Angor a Lightning zaps pro projekt.",
@ -936,6 +959,19 @@ export const csBatch4 = {
nip05Verified: "NIP-05 ověřeno",
nip05NotVerified: "NIP-05 neověřeno vůči tomuto klíči",
message: "Zpráva",
eventsHosted: "Uspořádané akce",
labServices: "Lab služby",
openToSponsorships: "Otevřeno sponzorům",
forSponsors: "Pro sponzory a spolupráce",
sponsorPitch: "Sponzorství, spolupráce, foto/video nebo akce? Začni konverzaci.",
startCollab: "Začít chat o spolupráci",
viewRiderCard: "Zobrazit kartu jezdce",
collabMessage: "Ahoj {{name}}, chtěl bych mluvit o sponzorství nebo spolupráci.",
followers: "Sledující",
followersHint: "Seznamy sledujících hlášené relayi — nejde o auditovanou analytiku.",
following: "Sleduji",
follow: "Sledovat",
unfollow: "Přestat sledovat",
zapsReceived: "přijatých zaps",
memberSince: "Členem od",
badges: "Odznaky",

View file

@ -663,6 +663,8 @@ const csBatch5 = {
manage: "Spravovat",
admin: "Admin",
editProfile: "Upravit profil",
transportSelfAttestedTitle: "Samodeklarovaný profil dopravce",
transportSelfAttested: "SkateSpots neověřuje řidičské průkazy, pojištění, technickou způsobilost vozidel ani oprávnění k provozu. Vycházej z odznaku důvěry, recenzí, záruk a vlastního úsudku.",
publishedTitle: "Jsi live",
publishedBody: "Tvůj profil je publikován v The Lab. Sdílej ho, aby tě skejťáci našli.",
manageRemove: "Odeber svůj profil {{noun}} z The Lab.",

View file

@ -1922,6 +1922,38 @@ export const csBatch6 = {
event: "Skate akce a soutěže"
}
},
transport: {
vehicle: {
car: "Auto",
taxi: "Taxi / spolujízda",
van: "Dodávka / minibus",
moto: "Motocykl",
helicopter: "Vrtulník",
plane: "Letadlo",
boat: "Loď",
other: "Jiné vozidlo"
},
service: {
people: "Osoby",
goods: "Zboží / náklad",
gear: "Skate výbava",
airport: "Transfer na letiště",
venue: "Transfer na místo / akci",
tours: "Výlety / roadtripy",
logistics: "Logistika akcí",
other: "Jiná služba"
},
feature: {
cargo: "Nákladový prostor",
bike_board: "Držák na kolo / prkno",
wheelchair: "Přístup pro vozíčkáře",
pets: "Mazlíčci vítáni",
border: "Překročení hranic",
night: "Jízda v noci",
covered: "Kryté / odolné počasí",
group: "Velké skupiny"
}
},
tour: {
skill: {
beginner: {

View file

@ -51,13 +51,40 @@ const drift = {
"tabClothing": "Oblečení",
"empty": {
"clothingTitle": "Zatím žádní návrháři oblečení",
"clothingBody": "Buď první, kdo nabídne oblečení na zakázku, týmové dresy a apparel pro scénu."
"clothingBody": "Buď první, kdo nabídne oblečení na zakázku, týmové dresy a apparel pro scénu.",
"transportTitle": "Zatím žádní dopravci",
"transportBody": "Převážíš skejťáky, vybavení nebo náklad? Nabídni své auto, dodávku, taxi, kolo, vrtulník, letadlo nebo loď.",
},
"craftFields": {
"sizing": "Rozsah velikostí",
"samples": "Vzorky / prototypy",
"customization": "Přizpůsobení"
}
"customization": "Přizpůsobení",
"capacity": "Kapacita",
"cargo": "Prostor pro náklad",
"coverage": "Pokrytí / trasy",
"availability": "Dostupnost",
"base": "Domovská oblast",
"accessibility": "Bezbariérový přístup",
},
"tabTransport": "Doprava",
"filterVehicles": "Vozidla",
"filterTransportServices": "Služby",
"filterCapabilities": "Možnosti",
"filterCapacity": "Kapacita",
"filterCoverage": "Pokrytí",
"offerTransport": "Nabídnout dopravu",
"labLink": {
"transport": "Doprava",
"designer": "Oblečení",
},
"detail": {
"noun": {
"transport": "dopravce",
},
"skillsTitle": {
"transport": "Vozidla a služby",
},
},
},
"socialUi": {
"coordinator": {
@ -82,6 +109,7 @@ const drift = {
},
"dms": {
"refresh": "Obnovit",
"privateRooms": "Soukromé místnosti",
"crews": "Crew",
"crewFounder": "Zakladatel",
"crewMember": "Člen",
@ -139,7 +167,10 @@ const drift = {
"bucketUnknownDesc": "Nikdo v tvé síti se za tento účet zatím nezaručil.",
"bucketFlaggedDesc": "Důvěryhodní lidé tento účet nahlásili.",
"reportedByTrusted": "Nahlášeno {{count}} důvěryhodnými lidmi"
}
},
"stats": {
"transport": "Doprava",
},
},
"trustUi": {
"alertsTitle": "Upozornění sítě",
@ -262,7 +293,23 @@ const drift = {
"leather": "Kůže",
"recycled": "Recyklované / deadstock"
}
}
},
"transport": {
"capacity": {
"solo": "1–3 osoby",
"small_group": "4–7 osob",
"large_group": "8+ osob",
"van_load": "Plná dodávka",
"freight": "Náklad / zboží",
},
"coverage": {
"local": "Místní",
"regional": "Regionální",
"national": "Celostátní",
"international": "Mezinárodní",
"cross_border": "Přeshraniční trasy",
},
},
},
"becomeRolesUi": {
"clothing": {
@ -281,7 +328,33 @@ const drift = {
"sizingPlaceholder": "např. XS-3XL / na míru",
"samplesHelper": "Nabízíš vzorky nebo prototypy před plnou sérií?",
"customizationPlaceholder": "např. jména, čísla, grafika"
}
},
"transport": {
"seoTitle": "Nabídni dopravu / řízení - SkateSpots",
"seoDescription": "Převážej skejťáky, vybavení a náklad — auta, taxi, dodávky, motorky, vrtulníky, letadla a lodě. Vlastní ověření, hodnoceno komunitou.",
"heading": "Doprava / řidič",
"tagline": "Voz lidi i vybavení po scéně — po zemi, vzduchem nebo po vodě",
"noun": "dopravce",
"rateLabel": "Sazba / jízda",
"portfolioLabel": "Fotky vozidla / flotily",
"sectionVehicles": "Vozidla",
"sectionServices": "Služby",
"sectionFeatures": "Možnosti",
"sectionCapacity": "Kapacita",
"sectionCoverage": "Pokrytí",
"capacityLabel": "Kapacita",
"capacityPlaceholder": "např. 4 cestující / 2 desky",
"cargoLabel": "Prostor pro náklad",
"cargoPlaceholder": "např. plná dodávka, 8 boxů na desky",
"coverageLabel": "Pokrytí / trasy",
"coveragePlaceholder": "např. Barcelona ↔ Perpignan, regionálně",
"availabilityLabel": "Dostupnost",
"availabilityPlaceholder": "např. víkendy, dny akcí, na zavolání",
"baseLabel": "Domovská oblast",
"basePlaceholder": "např. Barcelona / El Prat",
"accessibilityLabel": "Bezbariérový přístup",
"accessibilityPlaceholder": "např. rampa, místo pro vozík, asistence",
},
},
"weatherUi": {
"updated": "Aktualizováno",
@ -307,7 +380,10 @@ const drift = {
"noGroupMember": "V soukromém chatu této crew zatím nejsi.",
"create": "Vytvořit soukromý chat",
"requestJoin": "Požádat o připojení",
"requestAgain": "Request again",
"requestAgain": "Požádat znovu",
"requestHandled": "Žádost vyřízena",
"copyCordnLink": "Kopírovat odkaz Cordn",
"cordnWebCaveat": "Cordn web může po přihlášení zamrznout; připojení v aplikaci je spolehlivější.",
"checkInvite": "Zkontrolovat pozvánku",
"requested": "Žádost odeslána — administrátor crew ji schválí.",
"noInvite": "Zatím nečeká žádná pozvánka. Požádej administrátora o schválení žádosti.",
@ -317,6 +393,8 @@ const drift = {
"requests": "Žádosti o připojení",
"checkRequests": "Zkontrolovat žádosti",
"approve": "Přidat",
"approveTitle": "Schválit toto zařízení?",
"approveDesc": "Přidat {{name}} do této šifrované místnosti? Toto zařízení bude moci číst nové zprávy.",
"noRequests": "Žádné čekající žádosti o připojení.",
"inviteHint": "Zatím jsi v tomto chatu jen ty — členové crew mohou klepnout na „Požádat o připojení“ a ty je zde schválíš.",
"memberCount": "{{count}} ve skupině",
@ -345,7 +423,11 @@ const drift = {
onboardingMember3: "Jsi uvnitř — zprávy jsou šifrované.",
encryptedNote: "Číst to mohou jen členové crew; koordinátor pouze přenáší šifrované zprávy.",
advanced: "Pokročilé",
compareTitle: "Chat vs. soukromý chat",
compareChat: "Chat je standardní skupinová DM podle NIP-17. Funguje s jinými klienty Nostr a nemá žádného koordinátora.",
comparePrivate: "Soukromý chat používá MLS: silnější kontrolu členství a dopředné utajení. Koordinátor vidí metadata skupiny a šifrovaný text, nikdy obsah zpráv.",
coordinator: "Koordinátor",
lastChecked: "Naposledy zkontrolováno",
keyPackage: "Balíček klíčů",
keyPackageReady: "Připraveno na tomto zařízení",
keyPackageMissing: "Není nastaveno na tomto zařízení",
@ -369,6 +451,7 @@ const drift = {
qrTitle: "Naskenuj a připoj se k soukromému chatu",
qrHint: "Členové crew to naskenují kamerou telefonu. Odkaz otevře kartu Soukromý chat.",
sendFailed: "Nepodařilo se odeslat — klepni na Zkusit znovu.",
queued: "Ve frontě na tomto zařízení",
sending: "Odesílání…",
sent: "Odesláno",
retry: "Zkusit znovu",
@ -404,8 +487,8 @@ const drift = {
"noInvite": "Zatím žádná pozvánka. Bot obvykle schválí do minuty — potom klepni na „Zkontrolovat pozvánku“.",
"pendingRequests": "{{count}} čekajících žádostí o připojení",
"pendingRequestsDesc": "Bot obvykle schvaluje automaticky. Pokud se zasekl, otevři níže místnost a klepni na Zkontrolovat žádosti.",
"botMissing": "The community bot isn’t in this room right now, so an admin will review join requests.",
"secondDeviceHint": "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
"botMissing": "Komunitní bot teď v této místnosti není, takže žádosti o připojení zkontroluje administrátor.",
"secondDeviceHint": "Už jsi členem na jiném zařízení? Použij „Propojit toto zařízení“ pro přímé předání, nebo požádej o připojení znovu s tímto zařízením.",
"create": "Vytvořit komunitní místnost",
"setupDeviceBody": "Toto zařízení tím získá klíč do komunitní místnosti. Schválí ho jednou administrátor nebo bot.",
"onboardingFounder1": "Vytvoř komunitní místnost.",

View file

@ -349,6 +349,10 @@ const deBatch3 = {
bannerImage: "Bannerbild",
bannerDesc: "Ein breites Bild für den oberen Bereich deines Profils.",
website: "Website",
openToSponsorships: "Offen für Sponsoring",
openToSponsorshipsHint: "Zeigt eine Sponsoring-/Kooperationskarte in deinem Profil. Es wird nichts geteilt, bis dir jemand schreibt.",
sponsorNote: "Sponsoring-Notiz (optional)",
sponsorNotePlaceholder: "z. B. offen für Partner bei Decks, Schuhen, Reisen und Events.",
bitcoinTitle: "Bitcoin & Zahlungen",
bitcoinDesc: "Lass andere dich direkt zappen, bezahlen und dir Trinkgeld geben — ohne Mittelsmann.",
lightningAddress: "Lightning-Adresse",

View file

@ -688,8 +688,9 @@ const deBatch4 = {
judges: "Juroren",
organizers: "Organisatoren",
barbers: "Barbiere",
designers: "Designer:innen",
showFewer: "Weniger anzeigen",
showAllCrafts: "Alle 13 Handwerke anzeigen",
showAllCrafts: "Alle 15 Handwerke anzeigen",
community: "Community",
tours: "Touren",
crews: "Crews",
@ -807,6 +808,28 @@ const deBatch4 = {
our: "UNSERE",
community: "COMMUNITY",
heroDesc: "SkateSpots wird von und für die Community gebaut. Unterstützer, Mitwirkende, Partner und Freunde, die das hier kostenlos und dezentral halten.",
grantsSection: "Bitcoin-Förderorganisationen",
grantsSectionDesc: "Für Organisationen, die Zuschüsse an quelloffene Bitcoin-Projekte vergeben.",
grantsTitle: "Offen für Sponsoring · Bitcoin-Förderorganisationen",
grantsDisclaimer: "Du willst dieses Projekt unterstützen? Füll das Formular aus. Nicht um uns zu KYCen – sondern um DICH zu KYCen.",
grantsDisclaimerDesc: "Dieser Bereich ist nur für Förderorganisationen, die bei ihren Empfängern KYC/KYB durchführen. Wenn du ein Skater bist, der einfach nur spenden will, nutz die Lightning-, Silent-Payments- oder Angor-Wege oben – niemand braucht deinen Pass.",
grantsOrg: "Organisation",
grantsContact: "Kontakt (npub, E-Mail oder Nutzername)",
grantsWebsite: "Webseite",
grantsProgram: "Förderprogramm / Runde",
grantsSize: "Förderhöhe",
grantsKyc: "KYC-/KYB-Verfahren",
grantsFund: "Was möchtest du finanzieren?",
grantsAck: "Ich bestätige, dass wir eine Förderorganisation sind, die bei unseren Empfängern KYC/KYB durchführt.",
grantsSend: "Fördergespräch starten",
grantsNoKycTitle: "Kein KYC bei SkateSpots",
grantsNoKycDesc: "Wir verlangen weder deinen noch unseren Ausweis. Der KYC/KYB-Bereich ist für deine Empfänger.",
grantsNoTrackingTitle: "Kein Tracking",
grantsNoTrackingDesc: "Keine Analysen, kein CRM, keine Mailingliste. Diese Seite speichert nichts.",
grantsPrivateTitle: "Private Direktnachricht",
grantsPrivateDesc: "Öffnet eine verschlüsselte NIP-17-Konversation mit dem Betreiber.",
grantsEthos: "Wir nehmen nur Förderungen an, die SkateSpots quelloffen, selbst gehostet und frei von Analysen halten. Keine Bedingungen, die das Projekt schließen.",
grantsNote: "Öffnet eine private Direktnachricht an den Betreiber von SkateSpots. Auf dieser Seite wird nichts gespeichert.",
backAngor: "Unterstütze uns auf Angor",
backers: "Unterstützer",
backersDesc: "Skater, die SkateSpots unterstützt haben – On-Chain-Finanzierung über Angor und Lightning-Zaps an das Projekt.",
@ -936,6 +959,19 @@ const deBatch4 = {
nip05Verified: "NIP-05 verifiziert",
nip05NotVerified: "NIP-05 nicht gegen diesen Schlüssel verifiziert",
message: "Nachricht",
eventsHosted: "Organisierte Events",
labServices: "Lab-Dienste",
openToSponsorships: "Offen für Sponsoring",
forSponsors: "Für Sponsoren & Kooperationen",
sponsorPitch: "Sponsoring, Kooperation, Foto/Video oder Event? Starte ein Gespräch.",
startCollab: "Kooperations-Chat starten",
viewRiderCard: "Skateboarder-Karte ansehen",
collabMessage: "Hi {{name}}, ich würde gern über ein Sponsoring oder eine Kooperation sprechen.",
followers: "Follower",
followersHint: "Von Relays gemeldete Follower-Listen – keine geprüften Analysedaten.",
following: "Folge ich",
follow: "Folgen",
unfollow: "Entfolgen",
zapsReceived: "erhaltene Zaps",
memberSince: "Mitglied seit",
badges: "Abzeichen",

View file

@ -663,6 +663,8 @@ const deBatch5 = {
manage: "Verwalten",
admin: "Admin",
editProfile: "Profil bearbeiten",
transportSelfAttestedTitle: "Selbst angegebenes Transportprofil",
transportSelfAttested: "SkateSpots prüft keine Fahrerlaubnisse, Versicherungen, die Verkehrstauglichkeit von Fahrzeugen oder Betriebsgenehmigungen. Nutze das Vertrauensabzeichen, Bewertungen, Bürgschaften und dein eigenes Urteilsvermögen.",
publishedTitle: "Du bist live",
publishedBody: "Dein Profil ist in The Lab veröffentlicht. Teile es, damit Skater dich finden.",
manageRemove: "Entferne dein {{noun}}-Profil aus The Lab.",

View file

@ -2015,6 +2015,38 @@ const deBatch6 = {
event: "Skate-Events & Comps",
},
},
transport: {
vehicle: {
car: "Auto",
taxi: "Taxi / Fahrdienst",
van: "Van / Kleinbus",
moto: "Motorrad",
helicopter: "Hubschrauber",
plane: "Flugzeug",
boat: "Boot",
other: "Anderes Fahrzeug",
},
service: {
people: "Personen",
goods: "Waren / Fracht",
gear: "Skate-Ausrüstung",
airport: "Flughafentransfer",
venue: "Transfer zur Location / zum Event",
tours: "Touren / Roadtrips",
logistics: "Event-Logistik",
other: "Andere Dienstleistung",
},
feature: {
cargo: "Laderaum",
bike_board: "Fahrrad- / Boardträger",
wheelchair: "Rollstuhlgerecht",
pets: "Haustiere willkommen",
border: "Grenzüberschreitend",
night: "Nachtfahrten",
covered: "Überdacht / wetterfest",
group: "Große Gruppen",
}
},
tour: {
skill: {
beginner: {

View file

@ -54,11 +54,38 @@ const drift = {
empty: {
clothingTitle: "Noch keine Kleidungsdesigner",
clothingBody: "Sei der Erste, der individuelle Kleidung, Team-Kits und Apparel für die Szene anbietet.",
transportTitle: "Noch keine Transport-Anbieter",
transportBody: "Bewegst du Skater, Ausrüstung oder Fracht? Biete dein Auto, deinen Van, dein Taxi, Fahrrad, Helikopter, Flugzeug oder Boot an.",
},
craftFields: {
sizing: "Größenbereich",
samples: "Muster / Prototypen",
customization: "Individualisierung",
capacity: "Kapazität",
cargo: "Frachtraum",
coverage: "Abdeckung / Routen",
availability: "Verfügbarkeit",
base: "Standort",
accessibility: "Barrierefreiheit",
},
tabTransport: "Transport",
filterVehicles: "Fahrzeuge",
filterTransportServices: "Services",
filterCapabilities: "Möglichkeiten",
filterCapacity: "Kapazität",
filterCoverage: "Abdeckung",
offerTransport: "Transport anbieten",
labLink: {
transport: "Transport",
designer: "Kleidung",
},
detail: {
noun: {
transport: "Transport-Anbieter",
},
skillsTitle: {
transport: "Fahrzeuge & Services",
},
},
},
socialUi: {
@ -84,6 +111,7 @@ const drift = {
},
dms: {
refresh: "Aktualisieren",
privateRooms: "Private Räume",
crews: "Crews",
crewFounder: "Gründer",
crewMember: "Mitglied",
@ -142,6 +170,9 @@ const drift = {
bucketFlaggedDesc: "Vertrauenswürdige Personen haben dieses Konto gemeldet.",
reportedByTrusted: "Von {{count}} vertrauenswürdigen Personen gemeldet",
},
stats: {
transport: "Transport",
},
},
trustUi: {
alertsTitle: "Netzwerk-Warnungen",
@ -265,6 +296,22 @@ const drift = {
recycled: "Recycelt / Deadstock",
},
},
transport: {
capacity: {
solo: "1–3 Personen",
small_group: "4–7 Personen",
large_group: "8+ Personen",
van_load: "Van-Ladung",
freight: "Fracht / Ladung",
},
coverage: {
local: "Lokal",
regional: "Regional",
national: "National",
international: "International",
cross_border: "Grenzüberschreitende Routen",
},
},
},
becomeRolesUi: {
clothing: {
@ -284,6 +331,32 @@ const drift = {
samplesHelper: "Bietest du Muster oder Prototypen vor einer vollen Produktion an?",
customizationPlaceholder: "z. B. Namen, Nummern, Grafiken",
},
transport: {
seoTitle: "Transport anbieten / Fahren - SkateSpots",
seoDescription: "Bewege Skater, Ausrüstung und Fracht — Autos, Taxis, Vans, Motorräder, Helikopter, Flugzeuge und Boote. Selbst angegeben, von der Community bewertet.",
heading: "Transport / Fahrer",
tagline: "Bring Menschen und Ausrüstung durch die Szene — zu Land, in der Luft oder auf dem Wasser",
noun: "Transport-Anbieter",
rateLabel: "Preis / Fahrt",
portfolioLabel: "Fotos von Fahrzeug / Flotte",
sectionVehicles: "Fahrzeuge",
sectionServices: "Services",
sectionFeatures: "Was du kannst",
sectionCapacity: "Kapazität",
sectionCoverage: "Abdeckung",
capacityLabel: "Kapazität",
capacityPlaceholder: "z. B. 4 Passagiere / 2 Boards",
cargoLabel: "Frachtraum",
cargoPlaceholder: "z. B. Van-Ladung, 8 Deckboxen",
coverageLabel: "Abdeckung / Routen",
coveragePlaceholder: "z. B. Barcelona ↔ Perpignan, regional",
availabilityLabel: "Verfügbarkeit",
availabilityPlaceholder: "z. B. Wochenenden, Eventtage, auf Abruf",
baseLabel: "Standort",
basePlaceholder: "z. B. Barcelona / El Prat",
accessibilityLabel: "Barrierefreiheit",
accessibilityPlaceholder: "z. B. Rampe, Rollstuhlplatz, Assistenz",
},
},
weatherUi: {
updated: "Aktualisiert",
@ -309,7 +382,10 @@ const drift = {
noGroupMember: "Du bist noch nicht im privaten Chat dieser Crew.",
create: "Privaten Chat erstellen",
requestJoin: "Um Beitritt bitten",
requestAgain: "Request again",
requestAgain: "Erneut anfragen",
requestHandled: "Anfrage bearbeitet",
copyCordnLink: "Cordn-Link kopieren",
cordnWebCaveat: "Cordn Web kann nach der Anmeldung hängen bleiben; der Beitritt in der App ist zuverlässiger.",
checkInvite: "Nach Einladung suchen",
requested: "Anfrage gesendet – ein Crew-Admin wird sie genehmigen.",
noInvite: "Noch keine Einladung. Bitte einen Admin, deine Anfrage zu genehmigen.",
@ -319,6 +395,8 @@ const drift = {
requests: "Beitrittsanfragen",
checkRequests: "Anfragen prüfen",
approve: "Hinzufügen",
approveTitle: "Dieses Gerät bestätigen?",
approveDesc: "{{name}} zu diesem verschlüsselten Raum hinzufügen? Dieses Gerät kann dann neue Nachrichten lesen.",
noRequests: "Keine offenen Beitrittsanfragen.",
inviteHint: "Bisher bist nur du in diesem Chat – Crew-Mitglieder können auf „Um Beitritt bitten“ tippen, dann genehmigst du sie hier.",
memberCount: "{{count}} in der Gruppe",
@ -347,7 +425,11 @@ const drift = {
onboardingMember3: "Du bist dabei – Nachrichten sind verschlüsselt.",
encryptedNote: "Nur Crew-Mitglieder können das lesen; der Koordinator leitet nur verschlüsselte Nachrichten weiter.",
advanced: "Erweitert",
compareTitle: "Chat vs. privater Chat",
compareChat: "Der Chat ist eine standardmäßige NIP-17-Gruppen-DM. Er funktioniert mit anderen Nostr-Clients und hat keinen Koordinator.",
comparePrivate: "Der private Chat nutzt MLS: stärkere Mitgliederverwaltung und Forward Secrecy. Der Koordinator sieht Gruppenmetadaten und Chiffretext, niemals den Nachrichtentext.",
coordinator: "Koordinator",
lastChecked: "Zuletzt geprüft",
keyPackage: "Schlüsselpaket",
keyPackageReady: "Auf diesem Gerät bereit",
keyPackageMissing: "Auf diesem Gerät nicht eingerichtet",
@ -371,6 +453,7 @@ const drift = {
qrTitle: "Scannen und dem privaten Chat beitreten",
qrHint: "Crew-Mitglieder scannen das mit der Handykamera. Der Link öffnet den Tab „Privater Chat“.",
sendFailed: "Senden fehlgeschlagen – tippe auf Wiederholen.",
queued: "Auf diesem Gerät in der Warteschlange",
sending: "Senden…",
sent: "Gesendet",
retry: "Wiederholen",
@ -406,8 +489,8 @@ const drift = {
noInvite: "Noch keine Einladung. Der Bot genehmigt normalerweise innerhalb einer Minute – tippe danach auf „Nach Einladung suchen“.",
pendingRequests: "{{count}} offene Beitrittsanfragen",
pendingRequestsDesc: "Der Bot genehmigt normalerweise automatisch. Wenn es hängt, öffne unten den Raum und tippe auf Anfragen prüfen.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
botMissing: "Der Community-Bot ist gerade nicht in diesem Raum, daher prüft ein Admin die Beitrittsanfragen.",
secondDeviceHint: "Schon Mitglied auf einem anderen Gerät? Nutze „Dieses Gerät verknüpfen“ für eine direkte Übergabe oder bitte mit diesem Gerät erneut um Beitritt.",
create: "Community-Raum erstellen",
setupDeviceBody: "Damit erhält dieses Gerät einen Schlüssel für den Community-Raum. Ein Admin oder der Bot bestätigt ihn einmal.",
onboardingFounder1: "Erstelle den Community-Raum.",

View file

@ -349,6 +349,10 @@ const elBatch3 = {
bannerImage: "Εικόνα banner",
bannerDesc: "Μια φαρδιά εικόνα για το πάνω μέρος του προφίλ σου.",
website: "Ιστοσελίδα",
openToSponsorships: "Ανοιχτός σε χορηγίες",
openToSponsorshipsHint: "Εμφανίζει μια κάρτα χορηγίας/συνεργασίας στο προφίλ σου. Τίποτα δεν κοινοποιείται μέχρι να σου στείλει κάποιος μήνυμα.",
sponsorNote: "Σημείωση χορηγίας (προαιρετικό)",
sponsorNotePlaceholder: "π.χ. Ανοιχτός σε συνεργάτες για σανίδες, παπούτσια, ταξίδια και εκδηλώσεις.",
bitcoinTitle: "Bitcoin & Πληρωμές",
bitcoinDesc: "Άφησε τους άλλους να σου κάνουν zap, tip και να σε πληρώνουν απευθείας — χωρίς μεσάζοντα.",
lightningAddress: "Διεύθυνση Lightning",

View file

@ -688,8 +688,9 @@ const elBatch4 = {
judges: "Κριτές",
organizers: "Διοργανωτές",
barbers: "Κουρείς",
designers: "Σχεδιαστές",
showFewer: "Εμφάνιση λιγότερων",
showAllCrafts: "Εμφάνιση και των 13 ειδικοτήτων",
showAllCrafts: "Εμφάνιση και των 15 ειδικοτήτων",
community: "Κοινότητα",
tours: "Περιηγήσεις",
crews: "Crews",
@ -807,6 +808,28 @@ const elBatch4 = {
our: "Η ΔΙΚΗ ΜΑΣ",
community: "ΚΟΙΝΟΤΗΤΑ",
heroDesc: "Το SkateSpots χτίζεται από και για την κοινότητα. Υποστηρικτές, συνεργάτες, partners και φίλοι που το κρατούν δωρεάν και αποκεντρωμένο.",
grantsSection: "Χρηματοδότες του Bitcoin",
grantsSectionDesc: "Για οργανισμούς που χορηγούν επιχορηγήσεις σε έργα Bitcoin ανοιχτού κώδικα.",
grantsTitle: "Ανοιχτό σε χορηγίες · Χρηματοδότες του Bitcoin",
grantsDisclaimer: "Θέλεις να δωρίσεις σε αυτό το έργο; Συμπλήρωσε τη φόρμα. Όχι για να μας κάνουν KYC — αλλά για να σου κάνουν KYC ΕΣΕΝΑ.",
grantsDisclaimerDesc: "Αυτό το κανάλι είναι μόνο για χρηματοδότες που κάνουν KYC/KYB στους αποδέκτες τους. Αν είσαι skater και θέλεις απλώς να δωρίσεις, χρησιμοποίησε τις διαδρομές Lightning, Silent Payments ή Angor παραπάνω — κανείς δεν χρειάζεται το διαβατήριό σου.",
grantsOrg: "Οργανισμός",
grantsContact: "Επικοινωνία (npub, ηλεκτρονικό ταχυδρομείο ή ψευδώνυμο)",
grantsWebsite: "Ιστότοπος",
grantsProgram: "Πρόγραμμα / γύρος επιχορηγήσεων",
grantsSize: "Μέγεθος επιχορήγησης",
grantsKyc: "Διαδικασία KYC / KYB",
grantsFund: "Τι θα ήθελες να χρηματοδοτήσεις;",
grantsAck: "Επιβεβαιώνω ότι είμαστε χρηματοδότης που κάνει KYC/KYB στους αποδέκτες του.",
grantsSend: "Άνοιγμα συζήτησης για επιχορήγηση",
grantsNoKycTitle: "Χωρίς KYC του SkateSpots",
grantsNoKycDesc: "Δεν ζητάμε ποτέ την ταυτότητά σου ούτε τη δική μας. Το κανάλι KYC/KYB είναι για τους αποδέκτες σου.",
grantsNoTrackingTitle: "Χωρίς παρακολούθηση",
grantsNoTrackingDesc: "Χωρίς αναλυτικά, CRM ή λίστες αλληλογραφίας. Αυτή η σελίδα δεν αποθηκεύει τίποτα.",
grantsPrivateTitle: "Ιδιωτικό μήνυμα",
grantsPrivateDesc: "Ανοίγει μια κρυπτογραφημένη συνομιλία NIP-17 με τον ιδιοκτήτη.",
grantsEthos: "Δεχόμαστε μόνο επιχορηγήσεις που κρατούν το SkateSpots ανοιχτού κώδικα, αυτοφιλοξενούμενο και χωρίς αναλυτικά. Καμία δέσμευση που κλείνει το έργο.",
grantsNote: "Ανοίγει ιδιωτικό μήνυμα στον ιδιοκτήτη του SkateSpots. Δεν αποθηκεύεται τίποτα σε αυτή τη σελίδα.",
backAngor: "Στήριξέ μας στο Angor",
backers: "Υποστηρικτές",
backersDesc: "Skaters που στήριξαν το SkateSpots — χρηματοδότηση on-chain μέσω Angor και Lightning zaps στο έργο.",
@ -936,6 +959,19 @@ const elBatch4 = {
nip05Verified: "Επαληθευμένο NIP-05",
nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί",
message: "Μήνυμα",
eventsHosted: "Εκδηλώσεις που διοργάνωσες",
labServices: "Υπηρεσίες Lab",
openToSponsorships: "Ανοιχτός σε χορηγίες",
forSponsors: "Για χορηγούς και συνεργασίες",
sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.",
startCollab: "Έναρξη συνομιλίας συνεργασίας",
viewRiderCard: "Προβολή κάρτας σκέιτερ",
collabMessage: "Γεια σου {{name}}, θα ήθελα να μιλήσουμε για χορηγία ή συνεργασία.",
followers: "Ακόλουθοι",
followersHint: "Λίστες ακολούθων από τα relays — όχι ελεγμένα αναλυτικά στοιχεία.",
following: "Ακολουθείς",
follow: "Ακολούθησε",
unfollow: "Σταμάτα να ακολουθείς",
zapsReceived: "zaps που ελήφθησαν",
memberSince: "Μέλος από",
badges: "Badges",

View file

@ -663,6 +663,8 @@ const elBatch5 = {
manage: "Διαχείριση",
admin: "Διαχειριστής",
editProfile: "Επεξεργασία προφίλ",
transportSelfAttestedTitle: "Αυτοδηλωμένο προφίλ μεταφορέα",
transportSelfAttested: "Το SkateSpots δεν επαληθεύει άδειες οδήγησης, ασφαλίσεις, την καταλληλότητα των οχημάτων για κυκλοφορία ή άδειες εκμετάλλευσης. Χρησιμοποίησε το σήμα εμπιστοσύνης, τις κριτικές, τις εγγυήσεις και τη δική σου κρίση.",
publishedTitle: "Είσαι live",
publishedBody: "Το προφίλ σου δημοσιεύτηκε στο The Lab. Μοιράσου το ώστε να σε βρουν οι skaters.",
manageRemove: "Αφαίρεση του προφίλ {{noun}} από το The Lab.",

View file

@ -1922,6 +1922,38 @@ const elBatch6 = {
event: "Skate Events & Διαγωνισμοί"
}
},
transport: {
vehicle: {
car: "Αυτοκίνητο",
taxi: "Ταξί / Διαμοιρασμός διαδρομών",
van: "Βαν / Μίνι λεωφορείο",
moto: "Μοτοσυκλέτα",
helicopter: "Ελικόπτερο",
plane: "Αεροπλάνο",
boat: "Σκάφος",
other: "Άλλο όχημα"
},
service: {
people: "Άτομα",
goods: "Εμπορεύματα / Φορτίο",
gear: "Εξοπλισμός skate",
airport: "Μεταφορά από/προς αεροδρόμιο",
venue: "Μεταφορά σε χώρο / εκδήλωση",
tours: "Περιηγήσεις / Ταξίδια με αυτοκίνητο",
logistics: "Εφοδιαστική εκδηλώσεων",
other: "Άλλη υπηρεσία"
},
feature: {
cargo: "Χώρος φορτίου",
bike_board: "Σχάρα ποδηλάτου / σανίδας",
wheelchair: "Πρόσβαση για αμαξίδιο",
pets: "Κατοικίδια ευπρόσδεκτα",
border: "Διέλευση συνόρων",
night: "Νυχτερινή οδήγηση",
covered: "Καλυμμένο / αδιάβροχο",
group: "Μεγάλες ομάδες"
}
},
tour: {
skill: {
beginner: {

View file

@ -56,11 +56,38 @@ const drift = {
empty: {
clothingTitle: "Δεν υπάρχουν σχεδιαστές ρούχων ακόμη",
clothingBody: "Γίνε ο πρώτος που προσφέρει custom ρούχα, ομαδικά kits και ένδυση για τη σκηνή.",
transportTitle: "Δεν υπάρχουν πάροχοι μεταφορών ακόμη",
transportBody: "Μεταφέρεις skaters, εξοπλισμό ή φορτίο; Δήλωσε το αυτοκίνητο, το βαν, το ταξί, το ποδήλατο, το ελικόπτερο, το αεροπλάνο ή το σκάφος σου.",
},
craftFields: {
sizing: "Εύρος μεγεθών",
samples: "Δείγματα / πρωτότυπα",
customization: "Προσαρμογή",
capacity: "Χωρητικότητα",
cargo: "Χώρος φορτίου",
coverage: "Κάλυψη / διαδρομές",
availability: "Διαθεσιμότητα",
base: "Περιοχή βάσης",
accessibility: "Προσβασιμότητα",
},
tabTransport: "Μεταφορές",
filterVehicles: "Οχήματα",
filterTransportServices: "Υπηρεσίες",
filterCapabilities: "Δυνατότητες",
filterCapacity: "Χωρητικότητα",
filterCoverage: "Κάλυψη",
offerTransport: "Πρόσφερε μεταφορές",
labLink: {
transport: "Μεταφορές",
designer: "Ρούχα",
},
detail: {
noun: {
transport: "πάροχος μεταφορών",
},
skillsTitle: {
transport: "Οχήματα & υπηρεσίες",
},
},
},
socialUi: {
@ -86,6 +113,7 @@ const drift = {
},
dms: {
refresh: "Ανανέωση",
privateRooms: "Ιδιωτικά δωμάτια",
crews: "Crews",
crewFounder: "Ιδρυτής",
crewMember: "Μέλος",
@ -144,6 +172,9 @@ const drift = {
bucketFlaggedDesc: "Έμπιστα άτομα ανέφεραν αυτόν τον λογαριασμό.",
reportedByTrusted: "Αναφέρθηκε από {{count}} έμπιστα άτομα",
},
stats: {
transport: "Μεταφορές",
},
},
trustUi: {
alertsTitle: "Ειδοποιήσεις δικτύου",
@ -267,6 +298,22 @@ const drift = {
recycled: "Ανακυκλωμένο / Deadstock",
},
},
transport: {
capacity: {
solo: "1–3 άτομα",
small_group: "4–7 άτομα",
large_group: "8+ άτομα",
van_load: "Γεμάτο βαν",
freight: "Εμπόρευμα / φορτίο",
},
coverage: {
local: "Τοπικό",
regional: "Περιφερειακό",
national: "Εθνικό",
international: "Διεθνές",
cross_border: "Διασυνοριακές διαδρομές",
},
},
},
becomeRolesUi: {
clothing: {
@ -286,6 +333,32 @@ const drift = {
samplesHelper: "Προσφέρεις δείγματα ή πρωτότυπα πριν από πλήρη παραγωγή;",
customizationPlaceholder: "π.χ. ονόματα, νούμερα, γραφικά",
},
transport: {
seoTitle: "Πρόσφερε μεταφορές / οδήγηση - SkateSpots",
seoDescription: "Μετακίνησε skaters, εξοπλισμό και φορτία — αυτοκίνητα, ταξί, βαν, μοτοσυκλέτες, ελικόπτερα, αεροπλάνα και σκάφη. Δηλωμένα από εσένα, αξιολογημένα από την κοινότητα.",
heading: "Μεταφορές / Οδηγός",
tagline: "Μετέφερε ανθρώπους και εξοπλισμό στη σκηνή — στεριά, αέρας ή θάλασσα",
noun: "πάροχος μεταφορών",
rateLabel: "Χρέωση / διαδρομή",
portfolioLabel: "Φωτογραφίες οχήματος / στόλου",
sectionVehicles: "Οχήματα",
sectionServices: "Υπηρεσίες",
sectionFeatures: "Δυνατότητες",
sectionCapacity: "Χωρητικότητα",
sectionCoverage: "Κάλυψη",
capacityLabel: "Χωρητικότητα",
capacityPlaceholder: "π.χ. 4 επιβάτες / 2 σανίδες",
cargoLabel: "Χώρος φορτίου",
cargoPlaceholder: "π.χ. γεμάτο βαν, 8 κουτιά σανίδων",
coverageLabel: "Κάλυψη / διαδρομές",
coveragePlaceholder: "π.χ. Βαρκελώνη ↔ Περπινιάν, περιφερειακά",
availabilityLabel: "Διαθεσιμότητα",
availabilityPlaceholder: "π.χ. σαββατοκύριακα, μέρες εκδηλώσεων, κατόπιν κλήσης",
baseLabel: "Περιοχή βάσης",
basePlaceholder: "π.χ. Βαρκελώνη / El Prat",
accessibilityLabel: "Προσβασιμότητα",
accessibilityPlaceholder: "π.χ. ράμπα, θέση αναπηρικού αμαξιδίου, βοήθεια",
},
},
weatherUi: {
updated: "Ενημερώθηκε",
@ -311,7 +384,10 @@ const drift = {
noGroupMember: "Δεν είσαι ακόμη στην ιδιωτική συνομιλία αυτού του crew.",
create: "Δημιουργία ιδιωτικής συνομιλίας",
requestJoin: "Ζήτα συμμετοχή",
requestAgain: "Request again",
requestAgain: "Νέο αίτημα",
requestHandled: "Το αίτημα διεκπεραιώθηκε",
copyCordnLink: "Αντιγραφή συνδέσμου Cordn",
cordnWebCaveat: "Το Cordn web μπορεί να κολλήσει μετά τη σύνδεση· η συμμετοχή από την εφαρμογή είναι πιο αξιόπιστη.",
checkInvite: "Έλεγχος πρόσκλησης",
requested: "Το αίτημα στάλθηκε — ένας διαχειριστής του crew θα το εγκρίνει.",
noInvite: "Δεν περιμένει ακόμη πρόσκληση. Ζήτα από έναν διαχειριστή να εγκρίνει το αίτημά σου.",
@ -321,6 +397,8 @@ const drift = {
requests: "Αιτήματα συμμετοχής",
checkRequests: "Έλεγχος αιτημάτων",
approve: "Προσθήκη",
approveTitle: "Έγκριση αυτής της συσκευής;",
approveDesc: "Προσθήκη του {{name}} σε αυτή την κρυπτογραφημένη αίθουσα; Αυτή η συσκευή θα μπορεί να διαβάζει νέα μηνύματα.",
noRequests: "Δεν υπάρχουν εκκρεμή αιτήματα συμμετοχής.",
inviteHint: "Μόνο εσύ είσαι σε αυτή τη συνομιλία μέχρι στιγμής — τα μέλη του crew μπορούν να πατήσουν «Ζήτα συμμετοχή» και μετά να τα εγκρίνεις εδώ.",
memberCount: "{{count}} στην ομάδα",
@ -349,7 +427,11 @@ const drift = {
onboardingMember3: "Είστε μέσα — τα μηνύματα είναι κρυπτογραφημένα.",
encryptedNote: "Μόνο τα μέλη του crew μπορούν να το διαβάσουν· ο συντονιστής απλώς προωθεί κρυπτογραφημένα μηνύματα.",
advanced: "Για προχωρημένους",
compareTitle: "Συνομιλία vs. ιδιωτική συνομιλία",
compareChat: "Η συνομιλία είναι ένα τυπικό group DM του NIP-17. Λειτουργεί με άλλους πελάτες Nostr και δεν έχει συντονιστή.",
comparePrivate: "Η ιδιωτική συνομιλία χρησιμοποιεί MLS: ισχυρότερο έλεγχο μελών και εμπιστευτικότητα προς τα εμπρός. Ο συντονιστής βλέπει μεταδεδομένα ομάδας και κρυπτοκείμενο, ποτέ το κείμενο των μηνυμάτων.",
coordinator: "Συντονιστής",
lastChecked: "Τελευταίος έλεγχος",
keyPackage: "Πακέτο κλειδιών",
keyPackageReady: "Έτοιμο σε αυτή τη συσκευή",
keyPackageMissing: "Δεν έχει ρυθμιστεί σε αυτή τη συσκευή",
@ -373,6 +455,7 @@ const drift = {
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
sendFailed: "Δεν στάλθηκε — πατήστε Επανάληψη.",
queued: "Σε ουρά σε αυτή τη συσκευή",
sending: "Αποστολή…",
sent: "Στάλθηκε",
retry: "Επανάληψη",
@ -408,8 +491,8 @@ const drift = {
noInvite: "Δεν υπάρχει ακόμη πρόσκληση. Το bot εγκρίνει συνήθως μέσα σε ένα λεπτό — μετά πάτησε «Έλεγχος πρόσκλησης».",
pendingRequests: "{{count}} αιτήματα συμμετοχής σε αναμονή",
pendingRequestsDesc: "Το bot εγκρίνει συνήθως αυτόματα. Αν έχει κολλήσει, άνοιξε το δωμάτιο παρακάτω και πάτησε Έλεγχος αιτημάτων.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
botMissing: "Το bot της κοινότητας δεν είναι αυτή τη στιγμή σε αυτό το δωμάτιο, οπότε ένας διαχειριστής θα εξετάσει τα αιτήματα συμμετοχής.",
secondDeviceHint: "Είσαι ήδη μέλος σε άλλη συσκευή; Χρησιμοποίησε «Σύνδεση αυτής της συσκευής» για άμεση μεταβίβαση ή ζήτησε ξανά να συμμετάσχεις με αυτή τη συσκευή.",
create: "Δημιουργία δωματίου κοινότητας",
setupDeviceBody: "Αυτό δίνει σε αυτή τη συσκευή ένα κλειδί για το δωμάτιο της κοινότητας. Ένας διαχειριστής ή το bot το εγκρίνει μία φορά.",
onboardingFounder1: "Δημιούργησε το δωμάτιο της κοινότητας.",

View file

@ -2192,6 +2192,13 @@ labUi: {
tabJudges: 'Judges',
tabOrganizers: 'Organizers',
tabBarbers: 'Barbers',
tabTransport: 'Transport',
filterVehicles: 'Vehicles',
filterTransportServices: 'Services',
filterCapabilities: 'Capabilities',
filterCapacity: 'Capacity',
filterCoverage: 'Coverage',
offerTransport: 'Offer transport',
tabClothing: 'Clothing',
filterSkillsToLearn: 'Skills to Learn',
filterContentType: 'Content Type',
@ -2264,6 +2271,8 @@ labUi: {
barbersBody: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.',
clothingTitle: 'No clothing designers yet',
clothingBody: 'Be the first to offer custom clothes, team kits and apparel for the scene.',
transportTitle: 'No transport providers yet',
transportBody: 'Move skaters, gear or cargo? List your car, van, taxi, bike, helicopter, plane or boat.',
},
become: {
addNip05: 'Add NIP-05 in Settings',
@ -2681,6 +2690,8 @@ labUi: {
judge: 'Judge',
barber: 'Barber',
organizer: 'Organizer',
transport: 'Transport',
designer: 'Clothing',
lab: 'Lab',
linkedProfiles: 'Linked Lab Profiles',
desc: 'Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional. When the Lab profile also links back to this crew, both show a green',
@ -2770,6 +2781,8 @@ labUi: {
manage: 'Manage',
admin: 'Admin',
editProfile: 'Edit profile',
transportSelfAttestedTitle: 'Self-attested transport profile',
transportSelfAttested: 'SkateSpots does not verify licences, insurance, vehicle roadworthiness or operator permits. Use the trust badge, reviews, vouches and your own judgement.',
publishedTitle: "You're live",
publishedBody: 'Your profile is published to The Lab. Share it so skaters can find you.',
manageRemove: 'Remove your {{noun}} profile from The Lab.',
@ -2793,6 +2806,7 @@ labUi: {
organizer: 'event organizer',
barber: 'barber',
profile: 'profile',
transport: 'transport provider',
},
skillsTitle: {
skills: 'Skills',
@ -2806,6 +2820,7 @@ labUi: {
formatsSpecialties: 'Formats & Specialties',
servicesStyles: 'Services & Styles',
eventTypesServices: 'Event Types & Services',
transport: 'Vehicles & services',
},
},
rider: {
@ -2910,6 +2925,12 @@ labUi: {
sizing: 'Size Range',
samples: 'Samples / Prototypes',
customization: 'Customisation',
capacity: 'Capacity',
cargo: 'Cargo space',
coverage: 'Coverage / routes',
availability: 'Availability',
base: 'Base area',
accessibility: 'Accessibility',
},
craftForm: {
profileImage: 'Profile image',
@ -4090,6 +4111,7 @@ socialUi: {
newMessage: 'New Message',
groups: 'Groups',
refresh: 'Refresh',
privateRooms: 'Private rooms',
crews: 'Crews',
crewFounder: 'Founder',
crewMember: 'Member',
@ -4837,6 +4859,10 @@ settingsUi: {
bannerImage: 'Banner image',
bannerDesc: 'A wide image for the top of your profile.',
website: 'Website',
openToSponsorships: 'Open to sponsorships',
openToSponsorshipsHint: 'Shows a sponsor/collab card on your profile. Nothing is shared until someone messages you.',
sponsorNote: 'Sponsorship note (optional)',
sponsorNotePlaceholder: 'e.g. Open to deck, shoe, travel and event partners.',
bitcoinTitle: 'Bitcoin & Payments',
bitcoinDesc: 'Let people zap, tip and pay you directly — no middleman.',
lightningAddress: 'Lightning address',
@ -6495,8 +6521,10 @@ walletUi: {
judges: 'Judges',
organizers: 'Organizers',
barbers: 'Barbers',
transport: 'Transport',
designers: 'Designers',
showFewer: 'Show fewer',
showAllCrafts: 'Show all 13 crafts',
showAllCrafts: 'Show all 15 crafts',
community: 'Community',
tours: 'Tours',
crews: 'Crews',
@ -6614,6 +6642,28 @@ walletUi: {
our: 'OUR',
community: 'COMMUNITY',
heroDesc: 'SkateSpots is built by and for the community. Supporters, collaborators, partners and friends keeping this free and decentralized.',
grantsSection: 'Bitcoin grantmakers',
grantsSectionDesc: 'For organizations that issue grants to open-source Bitcoin projects.',
grantsTitle: 'Open to sponsorships · Bitcoin grantmakers',
grantsDisclaimer: 'Want to donate to this project? Fill the form. Not to KYC us — to KYC YOU.',
grantsDisclaimerDesc: "This lane is only for grantmakers that KYC/KYB their grantees. If you're a skater who just wants to donate, use the Lightning, Silent Payments or Angor rails above — nobody needs your passport.",
grantsOrg: 'Organization',
grantsContact: 'Contact (npub, email or handle)',
grantsWebsite: 'Website',
grantsProgram: 'Grant program / round',
grantsSize: 'Grant size',
grantsKyc: 'KYC / KYB process',
grantsFund: 'What would you like to fund?',
grantsAck: 'I confirm we are a grantmaker that KYC/KYB our grantees.',
grantsSend: 'Open grant conversation',
grantsNoKycTitle: 'No KYC of SkateSpots',
grantsNoKycDesc: 'We never ask for your ID or ours. The KYC/KYB lane is for your grantees.',
grantsNoTrackingTitle: 'No tracking',
grantsNoTrackingDesc: 'No analytics, CRM or mailing list. This page stores nothing.',
grantsPrivateTitle: 'Private DM',
grantsPrivateDesc: 'Opens an encrypted NIP-17 conversation with the owner.',
grantsEthos: 'We only take grants that keep SkateSpots open source, self-hosted and free of analytics. No strings that close the project.',
grantsNote: 'Opens a private DM to the SkateSpots owner. Nothing is stored on this page.',
backAngor: 'Back us on Angor',
backers: 'Supporters',
backersDesc: 'Skaters who backed SkateSpots — on-chain funding via Angor, and Lightning zaps to the project.',
@ -6743,6 +6793,19 @@ walletUi: {
nip05Verified: 'NIP-05 verified',
nip05NotVerified: 'NIP-05 not verified against this key',
message: 'Message',
eventsHosted: 'Events hosted',
labServices: 'Lab services',
openToSponsorships: 'Open to sponsorships',
forSponsors: 'For sponsors & collabs',
sponsorPitch: 'Sponsorship, collab, photo/video or event? Start a conversation.',
startCollab: 'Start a collab chat',
viewRiderCard: 'View rider card',
collabMessage: "Hi {{name}}, I'd like to talk about a sponsorship or collaboration.",
followers: 'Followers',
followersHint: 'Relay-reported follow lists — not audited analytics.',
following: 'Following',
follow: 'Follow',
unfollow: 'Unfollow',
zapsReceived: 'zaps received',
memberSince: 'Member since',
badges: 'Badges',

View file

@ -2192,6 +2192,13 @@ labUi: {
tabJudges: 'Jueces',
tabOrganizers: 'Organizadores',
tabBarbers: 'Barberos',
tabTransport: 'Transporte',
filterVehicles: 'Vehículos',
filterTransportServices: 'Servicios',
filterCapabilities: 'Capacidades',
filterCapacity: 'Capacidad',
filterCoverage: 'Cobertura',
offerTransport: 'Ofrecer transporte',
tabClothing: 'Ropa',
filterSkillsToLearn: 'Trucos que enseñar',
filterContentType: 'Tipo de contenido',
@ -2264,6 +2271,8 @@ labUi: {
barbersBody: '¿Cortas el pelo a skaters? Añade tu silla o servicio móvil para que la escena se dé un retoque.',
clothingTitle: 'Aún no hay diseñadores de ropa',
clothingBody: 'Sé el primero en ofrecer ropa personalizada, equipaciones y prendas para la escena.',
transportTitle: 'Aún no hay proveedores de transporte',
transportBody: '¿Mueves skaters, material o carga? Anuncia tu coche, furgoneta, taxi, moto, helicóptero, avión o barco.',
},
become: {
addNip05: 'Añade NIP-05 en Ajustes',
@ -2681,6 +2690,8 @@ labUi: {
judge: 'Juez',
barber: 'Barbero',
organizer: 'Organizador',
transport: 'Transporte',
designer: 'Ropa',
lab: 'Lab',
linkedProfiles: 'Perfiles del Lab vinculados',
desc: '¿También tienes un estudio, clases o un servicio de construcción? Vincula tus perfiles del Lab para que la gente pueda contratar a tu crew. Opcional. Cuando el perfil del Lab también enlaza de vuelta a este crew, ambos muestran una insignia',
@ -2770,6 +2781,8 @@ labUi: {
manage: 'Gestionar',
admin: 'Admin',
editProfile: 'Editar perfil',
transportSelfAttestedTitle: 'Perfil de transporte auto-declarado',
transportSelfAttested: 'SkateSpots no verifica licencias, seguros, aptitud del vehículo ni permisos de operador. Usa la insignia de confianza, las reseñas, los avales y tu propio criterio.',
publishedTitle: 'Ya estás visible',
publishedBody: 'Tu perfil está publicado en The Lab. Compártelo para que los skaters te encuentren.',
manageRemove: 'Elimina tu perfil de {{noun}} de The Lab.',
@ -2793,6 +2806,7 @@ labUi: {
organizer: 'organizador de eventos',
barber: 'barbero',
profile: 'perfil',
transport: 'proveedor de transporte',
},
skillsTitle: {
skills: 'Trucos',
@ -2806,6 +2820,7 @@ labUi: {
formatsSpecialties: 'Formatos y especialidades',
servicesStyles: 'Servicios y estilos',
eventTypesServices: 'Tipos de evento y servicios',
transport: 'Vehículos y servicios',
},
},
rider: {
@ -2910,6 +2925,12 @@ labUi: {
sizing: 'Tallas',
samples: 'Muestras / prototipos',
customization: 'Personalización',
capacity: 'Capacidad',
cargo: 'Espacio de carga',
coverage: 'Cobertura / rutas',
availability: 'Disponibilidad',
base: 'Zona base',
accessibility: 'Accesibilidad',
},
craftForm: {
profileImage: 'Imagen de perfil',
@ -4090,6 +4111,7 @@ socialUi: {
newMessage: 'Nuevo mensaje',
groups: 'Grupos',
refresh: 'Actualizar',
privateRooms: 'Salas privadas',
crews: 'Peñas',
crewFounder: 'Fundador',
crewMember: 'Miembro',
@ -4837,6 +4859,10 @@ settingsUi: {
bannerImage: 'Imagen de portada',
bannerDesc: 'Una imagen ancha para la parte superior de tu perfil.',
website: 'Sitio web',
openToSponsorships: 'Abierto a patrocinios',
openToSponsorshipsHint: 'Muestra una tarjeta de patrocinio/colaboración en tu perfil. No se comparte nada hasta que alguien te escriba.',
sponsorNote: 'Nota de patrocinio (opcional)',
sponsorNotePlaceholder: 'p. ej. Abierto a tablas, zapatillas, viajes y eventos.',
bitcoinTitle: 'Bitcoin y pagos',
bitcoinDesc: 'Deja que te den zaps, propinas y pagos directamente, sin intermediarios.',
lightningAddress: 'Dirección Lightning',
@ -6495,8 +6521,10 @@ walletUi: {
judges: 'Jueces',
organizers: 'Organizadores',
barbers: 'Barberos',
transport: 'Transporte',
designers: 'Diseñadores',
showFewer: 'Mostrar menos',
showAllCrafts: 'Mostrar los 13 oficios',
showAllCrafts: 'Mostrar los 15 oficios',
community: 'Comunidad',
tours: 'Rutas',
crews: 'Crews',
@ -6614,6 +6642,28 @@ walletUi: {
our: 'NUESTRA',
community: 'COMUNIDAD',
heroDesc: 'SkateSpots lo construye y lo mantiene la comunidad. Patrocinadores, colaboradores, socios y amigos que lo mantienen libre y descentralizado.',
grantsSection: 'Grantmakers de Bitcoin',
grantsSectionDesc: 'Para organizaciones que conceden ayudas a proyectos Bitcoin de código abierto.',
grantsTitle: 'Abierto a patrocinios · grantmakers de Bitcoin',
grantsDisclaimer: '¿Quieres donar a este proyecto? Rellena el formulario. No para hacernos KYC a nosotros, sino para hacerte KYC a TI.',
grantsDisclaimerDesc: 'Este canal es solo para grantmakers que hacen KYC/KYB a sus beneficiarios. Si eres un skater que solo quiere donar, usa las vías de Lightning, Silent Payments o Angor de arriba: nadie necesita tu pasaporte.',
grantsOrg: 'Organización',
grantsContact: 'Contacto (npub, email o usuario)',
grantsWebsite: 'Sitio web',
grantsProgram: 'Programa / ronda de ayudas',
grantsSize: 'Tamaño de la ayuda',
grantsKyc: 'Proceso de KYC / KYB',
grantsFund: '¿Qué te gustaría financiar?',
grantsAck: 'Confirmo que somos un grantmaker que hace KYC/KYB a sus beneficiarios.',
grantsSend: 'Abrir conversación de ayuda',
grantsNoKycTitle: 'Sin KYC de SkateSpots',
grantsNoKycDesc: 'Nunca pedimos tu identidad ni la nuestra. El canal KYC/KYB es para tus beneficiarios.',
grantsNoTrackingTitle: 'Sin rastreo',
grantsNoTrackingDesc: 'Sin analíticas, CRM ni listas de correo. Esta página no guarda nada.',
grantsPrivateTitle: 'DM privado',
grantsPrivateDesc: 'Abre una conversación cifrada NIP-17 con el propietario.',
grantsEthos: 'Solo aceptamos ayudas que mantengan SkateSpots como código abierto, autoalojado y sin analíticas. Nada que cierre el proyecto.',
grantsNote: 'Abre un DM privado al propietario de SkateSpots. No se guarda nada en esta página.',
backAngor: 'Apóyanos en Angor',
backers: 'Patrocinadores',
backersDesc: 'Patinadores que han apoyado SkateSpots — financiación on-chain vía Angor y zaps Lightning al proyecto.',
@ -6743,6 +6793,19 @@ walletUi: {
nip05Verified: 'NIP-05 verificado',
nip05NotVerified: 'NIP-05 no verificado con esta clave',
message: 'Mensaje',
eventsHosted: 'Eventos organizados',
labServices: 'Servicios de Lab',
openToSponsorships: 'Abierto a patrocinios',
forSponsors: 'Para patrocinadores y colaboraciones',
sponsorPitch: '¿Patrocinio, colaboración, foto/vídeo o evento? Empieza una conversación.',
startCollab: 'Iniciar chat de colaboración',
viewRiderCard: 'Ver tarjeta de rider',
collabMessage: 'Hola {{name}}, me gustaría hablar sobre un patrocinio o una colaboración.',
followers: 'Seguidores',
followersHint: 'Listas de seguidores reportadas por relays; no son analíticas auditadas.',
following: 'Siguiendo',
follow: 'Seguir',
unfollow: 'Dejar de seguir',
zapsReceived: 'zaps recibidos',
memberSince: 'Miembro desde',
badges: 'Insignias',

View file

@ -918,7 +918,10 @@ const batch1 = {
noGroupMember: "Oraindik ez zaude talde honen txat pribatuan.",
create: "Sortu txat pribatua",
requestJoin: "Eskatu sartzea",
requestAgain: "Request again",
requestAgain: "Eskatu berriro",
requestHandled: "Eskaera kudeatuta",
copyCordnLink: "Kopiatu Cordn esteka",
cordnWebCaveat: "Cordn web blokeatu egin daiteke saioa hasi ondoren; app barruan bat egitea fidagarriagoa da.",
checkInvite: "Egiaztatu gonbidapena",
requested: "Eskaera bidali da — taldeko administratzaile batek onartuko du.",
noInvite: "Oraindik ez dago gonbidapenik zain. Eskatu administratzaile bati zure eskaera onartzeko.",
@ -928,6 +931,8 @@ const batch1 = {
requests: "Sartzeko eskaerak",
checkRequests: "Egiaztatu eskaerak",
approve: "Gehitu",
approveTitle: "Gailu hau onartu?",
approveDesc: "{{name}} gela enkriptatu honetara gehitu? Gailu horrek mezu berriak irakurri ahal izango ditu.",
noRequests: "Ez dago sartzeko eskaera zain.",
inviteHint: "Oraindik zu soilik zaude txat honetan — taldeko kideek \"Eskatu sartzea\" sakatu dezakete, eta gero zuk hemen onartuko dituzu.",
shareInvite: "Gonbidatu",
@ -959,7 +964,11 @@ const batch1 = {
onboardingMember3: "Barruan zaude — mezuak zifratuta daude.",
encryptedNote: "Taldeko kideek soilik irakur dezakete hau; koordinatzaileak mezu zifratuak soilik birbidaltzen ditu.",
advanced: "Aurreratua",
compareTitle: "Txata vs txat pribatua",
compareChat: "Txata NIP-17 talde-DM estandar bat da. Beste Nostr bezeroekin funtzionatzen du eta ez du koordinatzailerik.",
comparePrivate: "Txat pribatuak MLS erabiltzen du: kideen kontrola sendoagoa eta aurreranzko sekretua. Koordinatzaileak taldearen metadatuak eta testu zifratua ikusten ditu, inoiz ez mezuen testua.",
coordinator: "Koordinatzailea",
lastChecked: "Azken egiaztapena",
keyPackage: "Gako-paketea",
keyPackageReady: "Prest gailu honetan",
keyPackageMissing: "Ez dago konfiguratuta gailu honetan",
@ -983,6 +992,7 @@ const batch1 = {
qrTitle: "Eskaneatu txat pribatura sartzeko",
qrHint: "Taldeko kideek telefonoaren kamerarekin eskaneatzen dute. Estekak Txat pribatua fitxa irekitzen du.",
sendFailed: "Ezin izan da bidali — sakatu Berriro saiatu.",
queued: "Gailu honetan ilaran",
sending: "Bidaltzen…",
sent: "Bidalita",
retry: "Berriro saiatu",
@ -1018,8 +1028,8 @@ const batch1 = {
noInvite: "Oraindik ez dago gonbidapenik. Botak normalean minutu batean onartzen du — gero sakatu «Egiaztatu gonbidapena».",
pendingRequests: "{{count}} sartzeko eskaera zain",
pendingRequestsDesc: "Botak normalean automatikoki onartzen du. Blokeatuta badago, ireki beheko gela eta sakatu Egiaztatu eskaerak.",
botMissing: "The community bot isn’t in this room right now, so an admin will review join requests.",
secondDeviceHint: "Already a member on another device? Use “Link device” instead — the bot can’t add a second key to the same account.",
botMissing: "Komunitatearen bot-a ez dago gela honetan oraintxe, beraz administratzaile batek berrikusiko ditu bat egiteko eskaerak.",
secondDeviceHint: "Beste gailu batean kide zara jada? Erabili «Estekatu gailu hau» zuzeneko transferentzia egiteko, edo eskatu berriro gailu honekin bat egiteko.",
create: "Sortu komunitate gela",
setupDeviceBody: "Honek gailu honi komunitate gelarako gako bat ematen dio. Administratzaile batek edo botak behin onartzen du.",
onboardingFounder1: "Sortu komunitate gela.",

View file

@ -395,6 +395,7 @@ const batch2 = {
newMessage: "Mezu berria",
groups: "Taldeak",
refresh: "Freskatu",
privateRooms: "Gela pribatuak",
crews: "Koadrilak",
crewFounder: "Sortzailea",
crewMember: "Kidea",

View file

@ -353,6 +353,10 @@ const batch3 = {
bannerImage: "Banner irudia",
bannerDesc: "Irudi zabal bat zure profilaren goialderako.",
website: "Webgunea",
openToSponsorships: "Babesleentzako irekita",
openToSponsorshipsHint: "Zure profilean babesle/lankidetza txartel bat erakusten du. Ez da ezer partekatzen norbaitek mezua idatzi arte.",
sponsorNote: "Babesleei buruzko oharra (aukerakoa)",
sponsorNotePlaceholder: "adib. Irekita taula, oinetako, bidaia eta ekitaldietako bazkideentzat.",
bitcoinTitle: "Bitcoin eta ordainketak",
bitcoinDesc: "Utzi jendeari zuzenean zap egiten, dirua ematen eta ordaintzen — bitartekaririk gabe.",
lightningAddress: "Lightning helbidea",

View file

@ -666,6 +666,8 @@ const batch5 = {
"manage": "Kudeatu",
"admin": "Administratzailea",
"editProfile": "Editatu profila",
"transportSelfAttestedTitle": "Autodeklaratutako garraio-profila",
"transportSelfAttested": "SkateSpots-ek ez du egiaztatzen gidabaimenik, asegururik, ibilgailuen zirkulazio-egokitasunik edo ustiapen-baimenik. Erabili konfiantza-distintagailua, iritziak, bermeak eta zure sen onena.",
"publishedTitle": "Zure profila argitaratuta dago",
"publishedBody": "Zure profila The Lab-en argitaratu da. Partekatu, skaterrek zu aurki zaitzaten.",
"manageRemove": "Kendu zure {{noun}} profila The Lab-etik.",
@ -1437,6 +1439,38 @@ const batch5 = {
"recycled": "Birziklatua / deadstock"
}
},
"transport": {
"vehicle": {
"car": "Autoa",
"taxi": "Taxi / VTC",
"van": "Furgoneta / minibus",
"moto": "Motozikleta",
"helicopter": "Helikopteroa",
"plane": "Hegazkina",
"boat": "Ontzia",
"other": "Beste ibilgailu bat"
},
"service": {
"people": "Pertsonak",
"goods": "Merkantziak / karga",
"gear": "Skate materiala",
"airport": "Aireporturako garraioa",
"venue": "Toki / ekitaldirako garraioa",
"tours": "Birak / errepide-bidaiak",
"logistics": "Ekitaldien logistika",
"other": "Beste zerbitzu bat"
},
"feature": {
"cargo": "Karga espazioa",
"bike_board": "Bizikleta / taula euskarria",
"wheelchair": "Gurpil-aulki sarbidea",
"pets": "Maskotak ongi etorriak",
"border": "Muga zeharkatzea",
"night": "Gaueko gidatzea",
"covered": "Estalia / eguraldiaren aurkakoa",
"group": "Talde handiak"
}
},
"tour": {
"skill": {
"beginner": {

View file

@ -714,8 +714,9 @@ const batch6 = {
judges: "Epaileak",
organizers: "Antolatzaileak",
barbers: "Bizarginak",
designers: "Diseinatzaileak",
showFewer: "Erakutsi gutxiago",
showAllCrafts: "Erakutsi 13 lanbide guztiak",
showAllCrafts: "Erakutsi 15 lanbide guztiak",
community: "Komunitatea",
tours: "Birak",
crews: "Crew-ak",
@ -833,6 +834,28 @@ const batch6 = {
our: "GURE",
community: "KOMUNITATEA",
heroDesc: "SkateSpots komunitateak egiten du eta komunitatearentzat da. Babesleek, kolaboratzaileek, bazkideek eta lagunek doakoa eta deszentralizatua mantentzen dute.",
grantsSection: "Bitcoin dirulaguntza emaileak",
grantsSectionDesc: "Kode irekiko Bitcoin proiektuei dirulaguntzak ematen dizkieten erakundeentzat.",
grantsTitle: "Babeslekuetara irekita · Bitcoin dirulaguntza emaileak",
grantsDisclaimer: "Proiektu honi eman nahi diozu? Bete formularioa. Ez guri KYC egiteko — ZURI KYC egiteko.",
grantsDisclaimerDesc: "Bide hau beren onuradunei KYC/KYB egiten dieten dirulaguntza emaileentzat soilik da. Donatu besterik ez duen skater bat bazara, erabili goiko Lightning, Silent Payments edo Angor bideak — inork ez du zure pasaportearen beharrik.",
grantsOrg: "Erakundea",
grantsContact: "Kontaktua (npub, helbide elektronikoa edo erabiltzaile-izena)",
grantsWebsite: "Webgunea",
grantsProgram: "Dirulaguntza programa / txanda",
grantsSize: "Dirulaguntzaren zenbatekoa",
grantsKyc: "KYC / KYB prozesua",
grantsFund: "Zer finantzatu nahi duzu?",
grantsAck: "Baieztatzen dut gure onuradunei KYC/KYB egiten dien dirulaguntza emailea garela.",
grantsSend: "Hasi dirulaguntzaren elkarrizketa",
grantsNoKycTitle: "SkateSpots-en KYCrik ez",
grantsNoKycDesc: "Ez dizugu inoiz zure agiririk eskatzen, ezta gurea ere. KYC/KYB bidea zure onuradunentzat da.",
grantsNoTrackingTitle: "Jarraipenik ez",
grantsNoTrackingDesc: "Ez analitikarik, ez CRMrik, ez posta-zerrendarik. Orri honek ez du ezer gordetzen.",
grantsPrivateTitle: "Mezu pribatua",
grantsPrivateDesc: "Jabearekin NIP-17 elkarrizketa zifratua irekitzen du.",
grantsEthos: "SkateSpots kode irekian, norberak ostatatuta eta analitikarik gabe mantentzen duten dirulaguntzak soilik onartzen ditugu. Proiektua ixten duen baldintzarik ez.",
grantsNote: "SkateSpots-en jabeari mezu pribatua irekitzen dio. Orri honetan ez da ezer gordetzen.",
backAngor: "Babestu gaitzazu Angor-en",
backers: "Babesleak",
backersDesc: "SkateSpots babestu zuten skaterrak — Angor bidezko katean egindako finantzaketa eta proiektura zuzendutako Lightning zapak.",
@ -962,6 +985,19 @@ const batch6 = {
nip05Verified: "NIP-05 egiaztatua",
nip05NotVerified: "NIP-05 ez da gako honekin egiaztatu",
message: "Mezua",
eventsHosted: "Antolatutako ekitaldiak",
labServices: "Lab zerbitzuak",
openToSponsorships: "Babesleentzako irekita",
forSponsors: "Babesleentzat eta lankidetzetarako",
sponsorPitch: "Babesa, lankidetza, argazki/bideoa edo ekitaldia? Hasi solasaldia.",
startCollab: "Hasi lankidetza-txata",
viewRiderCard: "Ikusi patinatzailearen txartela",
collabMessage: "Kaixo {{name}}, babes edo lankidetza bati buruz hitz egin nahi nuke.",
followers: "Jarraitzaileak",
followersHint: "Relayek jakinarazitako jarraitzaileen zerrendak — ez dira auditutako analisiak.",
following: "Jarraitzen",
follow: "Jarraitu",
unfollow: "Utzi jarraitzeari",
zapsReceived: "jasotako zapak",
memberSince: "Kidea noiztik",
badges: "Dominak",

View file

@ -19,6 +19,90 @@ const drift = {
autoTranslateDesc: 'Itzuli sarrerako mezuak zure hizkuntzara. Gailu honetan exekutatzen da; ez da ezer bidaltzen.',
privacy: 'Itzulpena zure gailuan exekutatzen da. Zure mezua ez da inoiz hemendik ateratzen. Lehen erabileran hizkuntza-eredu bat deskargatzen da.',
},
labUi: {
tabTransport: 'Garraioa',
filterVehicles: 'Ibilgailuak',
filterTransportServices: 'Zerbitzuak',
filterCapabilities: 'Gaitasunak',
filterCapacity: 'Edukiera',
filterCoverage: 'Estaldura',
offerTransport: 'Eskaini garraioa',
empty: {
transportTitle: 'Oraindik ez dago garraio-hornitzailearik',
transportBody: 'Skaterrak, ekipamendua edo zama mugitu behar dituzu? Argitaratu zure autoa, furgoneta, taxia, bizikleta, helikopteroa, hegazkina edo itsasontzia.',
},
labLink: {
transport: 'Garraioa',
designer: 'Arropa',
},
detail: {
noun: {
transport: 'garraio-hornitzailea',
},
skillsTitle: {
transport: 'Ibilgailuak eta zerbitzuak',
},
},
craftFields: {
capacity: 'Edukiera',
cargo: 'Zama-eremua',
coverage: 'Estaldura / ibilbideak',
availability: 'Erabilgarritasuna',
base: 'Oinarri-eremua',
accessibility: 'Irisgarritasuna',
},
},
walletUi: {
stats: {
transport: 'Garraioa',
},
},
becomeRolesUi: {
transport: {
seoTitle: 'Garraioa / gidatzea eskaini - SkateSpots',
seoDescription: 'Eraman skaterrak, ekipamendua eta zama — autoak, taxia, furgonetak, motoak, helikopteroak, hegazkinak eta itsasontziak. Autodeklaratua, komunitateak berrikusia.',
heading: 'Garraioa / Gidaria',
tagline: 'Eraman jendea eta ekipamendua eszenan zehar — lehorrean, airean edo itsasoan',
noun: 'garraio-hornitzailea',
rateLabel: 'Tasa / Bidaia',
portfolioLabel: 'Ibilgailuaren / flotaren argazkiak',
sectionVehicles: 'Ibilgailuak',
sectionServices: 'Zerbitzuak',
sectionFeatures: 'Zer eskain dezakezun',
sectionCapacity: 'Edukiera',
sectionCoverage: 'Estaldura',
capacityLabel: 'Edukiera',
capacityPlaceholder: 'adib. 4 bidaiari / 2 taula',
cargoLabel: 'Zama-eremua',
cargoPlaceholder: 'adib. furgoneta betea, 8 taula-kutxa',
coverageLabel: 'Estaldura / ibilbideak',
coveragePlaceholder: 'adib. Bilbo ↔ Donostia, eskualdekoa',
availabilityLabel: 'Erabilgarritasuna',
availabilityPlaceholder: 'adib. asteburuak, ekitaldi-egunak, deituta',
baseLabel: 'Oinarri-eremua',
basePlaceholder: 'adib. Bilbo / Getxo',
accessibilityLabel: 'Irisgarritasuna',
accessibilityPlaceholder: 'adib. arrapala, gurpil-aulkirako lekua, laguntza',
},
},
labOptions: {
transport: {
capacity: {
solo: '1–3 pertsona',
small_group: '4–7 pertsona',
large_group: '8+ pertsona',
van_load: 'Furgoneta karga',
freight: 'Zama / karga',
},
coverage: {
local: 'Tokikoa',
regional: 'Eskualdekoa',
national: 'Nazionala',
international: 'Nazioartekoa',
cross_border: 'Mugaz gaindiko ibilbideak',
},
},
},
};
export default drift;

View file

@ -349,6 +349,10 @@ const frBatch3 = {
bannerImage: "Image de bannière",
bannerDesc: "Une image large pour le haut de votre profil.",
website: "Site web",
openToSponsorships: "Ouvert au sponsoring",
openToSponsorshipsHint: "Affiche une carte sponsoring/collab sur ton profil. Rien n'est partagé tant que personne ne t'écrit.",
sponsorNote: "Note de sponsoring (facultatif)",
sponsorNotePlaceholder: "p. ex. ouvert aux partenaires decks, chaussures, voyages et événements.",
bitcoinTitle: "Bitcoin et paiements",
bitcoinDesc: "Laissez les gens vous zapper, vous donner un pourboire et vous payer directement — sans intermédiaire.",
lightningAddress: "Adresse Lightning",

View file

@ -688,8 +688,9 @@ const frBatch4 = {
judges: "Juges",
organizers: "Organisateurs",
barbers: "Coiffeurs",
designers: "Concepteurs",
showFewer: "Afficher moins",
showAllCrafts: "Afficher les 13 disciplines",
showAllCrafts: "Afficher les 15 disciplines",
community: "Communauté",
tours: "Tours",
crews: "Crews",
@ -807,6 +808,28 @@ const frBatch4 = {
our: "NOTRE",
community: "COMMUNAUTÉ",
heroDesc: "SkateSpots est construit par et pour la communauté. Soutiens, collaborateurs, partenaires et amis qui gardent ce projet gratuit et décentralisé.",
grantsSection: "Bailleurs de fonds Bitcoin",
grantsSectionDesc: "Pour les organisations qui accordent des subventions à des projets Bitcoin libres.",
grantsTitle: "Ouverts aux parrainages · Bailleurs de fonds Bitcoin",
grantsDisclaimer: "Vous voulez faire un don à ce projet ? Remplissez le formulaire. Pas pour nous faire un KYC — mais pour VOUS en faire un.",
grantsDisclaimerDesc: "Cette voie est réservée aux bailleurs de fonds qui font un KYC/KYB de leurs bénéficiaires. Si vous êtes un skateur qui veut juste faire un don, utilisez les rails Lightning, Silent Payments ou Angor ci-dessus — personne n'a besoin de votre passeport.",
grantsOrg: "Organisation",
grantsContact: "Contact (npub, courriel ou pseudonyme)",
grantsWebsite: "Site web",
grantsProgram: "Programme / cycle de subventions",
grantsSize: "Montant de la subvention",
grantsKyc: "Processus KYC / KYB",
grantsFund: "Que souhaitez-vous financer ?",
grantsAck: "Je confirme que nous sommes un bailleur de fonds qui fait un KYC/KYB de ses bénéficiaires.",
grantsSend: "Ouvrir la discussion sur la subvention",
grantsNoKycTitle: "Aucun KYC de SkateSpots",
grantsNoKycDesc: "Nous ne demandons jamais votre pièce d’identité, ni la nôtre. La voie KYC/KYB est pour vos bénéficiaires.",
grantsNoTrackingTitle: "Aucun suivi",
grantsNoTrackingDesc: "Pas d’analytique, de CRM ni de liste de diffusion. Cette page ne stocke rien.",
grantsPrivateTitle: "Message privé",
grantsPrivateDesc: "Ouvre une conversation chiffrée NIP-17 avec le propriétaire.",
grantsEthos: "Nous n’acceptons que des subventions qui gardent SkateSpots libre, auto-hébergé et sans analytique. Aucune condition qui ferme le projet.",
grantsNote: "Ouvre un message privé au propriétaire de SkateSpots. Rien n'est stocké sur cette page.",
backAngor: "Soutenez-nous sur Angor",
backers: "Soutiens",
backersDesc: "Skaters qui ont soutenu SkateSpots — financement on-chain via Angor, et zaps Lightning au projet.",
@ -936,6 +959,19 @@ const frBatch4 = {
nip05Verified: "NIP-05 vérifié",
nip05NotVerified: "NIP-05 non vérifié pour cette clé",
message: "Message",
eventsHosted: "Événements organisés",
labServices: "Services Lab",
openToSponsorships: "Ouvert au sponsoring",
forSponsors: "Pour les sponsors et collaborations",
sponsorPitch: "Sponsoring, collaboration, photo/vidéo ou événement ? Lance la conversation.",
startCollab: "Démarrer une discussion de collaboration",
viewRiderCard: "Voir la fiche du skateur",
collabMessage: "Salut {{name}}, j'aimerais parler d'un sponsoring ou d'une collaboration.",
followers: "Abonnés",
followersHint: "Listes d'abonnés rapportées par les relais — pas des statistiques auditées.",
following: "Abonnements",
follow: "Suivre",
unfollow: "Ne plus suivre",
zapsReceived: "zaps reçus",
memberSince: "Membre depuis",
badges: "Badges",

Some files were not shown because too many files have changed in this diff Show more