Compare commits
129 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce57bedf9 | ||
|
|
bfc777c18b | ||
|
|
a2b732317e | ||
|
|
5ebf741427 | ||
|
|
4ae30ee8e6 | ||
|
|
72e5f89b49 | ||
|
|
d003748bb1 | ||
|
|
6048eafc0d | ||
|
|
562a7de7c0 | ||
|
|
8b12001fa6 | ||
|
|
383ff8a5cf | ||
|
|
65914144d1 | ||
|
|
a8780d5236 | ||
|
|
2dcf5a2aad | ||
|
|
50c95d0ff7 | ||
|
|
4ff736734d | ||
|
|
fd1713f6b1 | ||
|
|
6c9b6fab02 | ||
|
|
0ae4c59630 | ||
|
|
ab11b0d3ed | ||
|
|
0355c10dbc | ||
|
|
28d34e3fbe | ||
|
|
b0df599b52 | ||
|
|
74bd140295 | ||
|
|
42f47304dd | ||
|
|
c00bffdf78 | ||
|
|
5026b63323 | ||
|
|
601393ece3 | ||
|
|
ee6db357c2 | ||
|
|
cdf4a63809 | ||
|
|
23929653d3 | ||
|
|
344b41afc8 | ||
|
|
52a84479d4 | ||
|
|
f71f5eeef8 | ||
|
|
1f27311903 | ||
|
|
465683cf71 | ||
|
|
a4f72a3d05 | ||
|
|
c4a1671f81 | ||
|
|
20b2b0d676 | ||
|
|
95ff486038 | ||
|
|
7791cbe9fa | ||
|
|
26a8c5eb31 | ||
|
|
69a86493e8 | ||
|
|
f2787d79cf | ||
|
|
a1b069da13 | ||
|
|
c65e6effb5 | ||
|
|
b39c2ec386 | ||
|
|
22eb46c62b | ||
|
|
65a7f0bdbb | ||
|
|
f95adeecf1 | ||
|
|
f8a1054894 | ||
|
|
7958ca59bc | ||
|
|
68b77d6c14 | ||
|
|
8a347b4435 | ||
|
|
3d905af6ec | ||
|
|
1c361b8df5 | ||
|
|
20ff45089f | ||
|
|
e22e2ec85f | ||
|
|
dbc78a1760 | ||
|
|
0175645f3a | ||
|
|
008fb81f73 | ||
|
|
9838aa6d72 | ||
|
|
a9dffbf09d | ||
|
|
afd5a09acd | ||
|
|
e234253870 | ||
|
|
c66aeb526f | ||
|
|
267e93abc7 | ||
|
|
9dd2231332 | ||
|
|
1a88f8637d | ||
|
|
b95371a123 | ||
|
|
b76891798e | ||
|
|
7dc8c2c08b | ||
|
|
822cca23d7 | ||
|
|
c36bf2f029 | ||
|
|
443c7122d8 | ||
|
|
2d9758593a | ||
|
|
44be542366 | ||
|
|
7719be12d9 | ||
|
|
6aba9b356f | ||
|
|
7a928dae6d | ||
|
|
bf47d43674 | ||
|
|
41f420ae55 | ||
|
|
262eb32e7c | ||
|
|
dddfe51f86 | ||
|
|
b94ac3d409 | ||
|
|
eaf345e2ec | ||
|
|
96503b672c | ||
|
|
b0f6dd9f77 | ||
|
|
6746353670 | ||
|
|
096f2155fe | ||
|
|
5d40a67a8a | ||
|
|
569ad9b01c | ||
|
|
d3d82f7d46 | ||
|
|
4a4f398df0 | ||
|
|
957033e678 | ||
|
|
7f29a822ab | ||
|
|
3f35a6f3a4 | ||
|
|
8a6968735c | ||
|
|
d921a6c090 | ||
|
|
bc1987f78c | ||
|
|
9c22bd78f8 | ||
|
|
4483f82fe4 | ||
|
|
a2ab5750ff | ||
|
|
d07f8a696d | ||
|
|
bde3d2484f | ||
|
|
c75ddea8e2 | ||
|
|
dcb1a3e674 | ||
|
|
e02f2b4699 | ||
|
|
cded5eddb4 | ||
|
|
f94abcf33e | ||
|
|
5536eee2ff | ||
|
|
69ce773e0f | ||
|
|
89e6438ee1 | ||
|
|
56c718d216 | ||
|
|
d6f8345281 | ||
|
|
65d90da26f | ||
|
|
6b5a2a7daa | ||
|
|
444597be2f | ||
|
|
673bb983b8 | ||
|
|
be00aa3fed | ||
|
|
fc7060a01d | ||
|
|
c09f1ff23d | ||
|
|
ca1e5a25dc | ||
|
|
8b895a4858 | ||
|
|
68647716d3 | ||
|
|
d729f43692 | ||
|
|
7a797c0fc6 | ||
|
|
db6d420d26 | ||
|
|
8c619e8320 |
254 changed files with 13449 additions and 4068 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
415
AGENTS.md
415
AGENTS.md
|
|
@ -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.
|
||||
|
|
@ -2512,6 +2654,223 @@ Rules:
|
|||
(`lib/dmOwnWraps.ts`), and shows a Messages row/link with a badge; opening
|
||||
Messages acknowledges. 1:1 + group unread dots clear on read. APK sha256
|
||||
`8d658e5b…`, versionCode 10721. Web forced live at `9baa1bd`.
|
||||
- **1.7.22 (2026-10-28):** map remembers center/zoom
|
||||
(`skatespots:map-view`), in addition to Events/Crews layer persistence;
|
||||
first-run checklist includes the Community room. APK sha256 `614b0387…`,
|
||||
versionCode 10722. Web forced live at `c163666`.
|
||||
- **1.7.23 (2026-10-29):** first-run tip made frictionless — only optional
|
||||
Near Me + Got it; no Add Spot/Crew/Wallet/Community CTAs and no crew query.
|
||||
Inline **Mute** added to spot/event/tour/gear card menus
|
||||
(`components/MuteMenuItem.tsx`, NIP-51 mute list); report + author hidden
|
||||
notice already existed. APK sha256 `d49837e7…`, versionCode 10723. Web
|
||||
forced live at `d729f4369`.
|
||||
- **1.7.24 (2026-10-29):** Gear Swap saved searches
|
||||
(`skatespots:gear-saved-searches`, apply/delete), quick offer-note
|
||||
templates (`marketplaceExtrasUi`), and a collapsible buyer/seller safety
|
||||
guide linking `/marketplace-guidelines`. APK sha256 `3ddee2f7…`,
|
||||
versionCode 10724. Web forced live at `ca1e5a2`.
|
||||
- **1.7.25 (2026-10-29):** event detail shows the viewer's IANA timezone
|
||||
under the local time, and the Gear Swap load-error card has a real Retry.
|
||||
APK sha256 `b528e500…`, versionCode 10725. Web forced live at `fc7060a`.
|
||||
- **1.7.26 (2026-10-29):** per-thread mute for 1:1 and group DMs
|
||||
(`hooks/useDmMute.ts`, device-local, header toggle + inbox unread
|
||||
suppression), card **Unmute** to undo a NIP-51 mute, event **Pass**
|
||||
shortcut (`/card?type=event&event=<id>`) for organisers and RSVP'd
|
||||
users, 200% text header truncation, and one quiet *Find a crew* link in
|
||||
the first-run tip. APK sha256 `7948f2a6…`, versionCode 10726. Web
|
||||
forced live at `444597b`.
|
||||
- **1.7.27 (2026-10-29):** mobile map **Local Chat** icon button got an
|
||||
accessible name; first-run close/Find a crew controls got a visible
|
||||
keyboard focus ring; the bell's encrypted-message row is now
|
||||
**“Messages & crew chats”** (crew chat is NIP-17 group envelopes, so it
|
||||
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):**
|
||||
|
|
@ -2572,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
|
||||
|
|
@ -2815,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
|
||||
|
|
@ -2843,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
|
||||
|
|
@ -2854,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
38
NIP.md
|
|
@ -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
97
distribution/README.md
Normal 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.
|
||||
51
distribution/fdroiddata/org.skatespots.app.yml
Normal file
51
distribution/fdroiddata/org.skatespots.app.yml
Normal 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
|
||||
1
fastlane/metadata/android/en-US/changelogs/10761.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10761.txt
Normal 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.
|
||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
15
fastlane/metadata/android/en-US/full_description.txt
Normal 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.
|
||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Local-first skate map, crews, events and marketplace.
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
SkateSpots
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
10
netlify.toml
10
netlify.toml
|
|
@ -23,8 +23,10 @@
|
|||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
# Human-friendly short links.
|
||||
# /community -> the app's Community page (HashRouter route).
|
||||
# /c -> the Cordn room reference (short link for sharing).
|
||||
# /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"
|
||||
|
|
@ -34,13 +36,13 @@
|
|||
|
||||
[[redirects]]
|
||||
from = "/c"
|
||||
to = "https://cordn.net/chat/cordn1qgdhwumn8ghj7un9d3shjtnnddshgefdwdcx7arn9ehhyecpyqpgz6s9k82wwznal8r39mm0wfe8wsnf6eda3ghjvt8qxvxs3ptfxqq5wd4kzar9wdcx7arn8f3k7mtdw4hxjare8hqt74"
|
||||
to = "/#/community"
|
||||
status = 302
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/cordn"
|
||||
to = "https://cordn.net/chat/cordn1qgdhwumn8ghj7un9d3shjtnnddshgefdwdcx7arn9ehhyecpyqpgz6s9k82wwznal8r39mm0wfe8wsnf6eda3ghjvt8qxvxs3ptfxqq5wd4kzar9wdcx7arn8f3k7mtdw4hxjare8hqt74"
|
||||
to = "/#/community"
|
||||
status = 302
|
||||
force = true
|
||||
|
||||
|
|
|
|||
5679
package-lock.json
generated
5679
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"private": true,
|
||||
"version": "1.7.22",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
127
public/download/index.html
Normal 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>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,448 @@
|
|||
🧩 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
|
||||
for Staircase/Cordn clients.
|
||||
- Join requests now say the community bot or an admin approves them, with a
|
||||
Request again action and an admin banner when requests are waiting.
|
||||
- Second-device users are told to use Link device; the bot cannot add a second
|
||||
MLS key for the same account.
|
||||
- The community bot now retires handled and stale requests (fixing requests
|
||||
stuck on “Join request sent”), prefers the exact key package, logs pending
|
||||
queues, and stops automatic approval at 250 members or 5 devices per account.
|
||||
- The room warns when the bot is missing, so admins know to review manually.
|
||||
|
||||
---
|
||||
|
||||
♿ Accessibility and clearer crew-chat aggregation
|
||||
- The mobile map Local Chat button now has a screen-reader name and title.
|
||||
- The first-run tip's close and “Find a crew” controls get a visible keyboard
|
||||
focus ring.
|
||||
- The bell's message row is now “Messages & crew chats”, making it clear that
|
||||
crew-chat envelopes are part of that single encrypted unread count (still
|
||||
envelope-only, still no background decryption).
|
||||
|
||||
---
|
||||
|
||||
💬 Per-thread mute, event passes, and unmute
|
||||
- 1:1 and group DM threads have a mute toggle in the thread header; muted
|
||||
threads stop showing unread dots on the inbox. Your choice is device-local.
|
||||
- Card Mute actions can now be undone: muted accounts show “Unmute”.
|
||||
- Event pages get an Event Pass shortcut when you’re the organiser or RSVP’d
|
||||
going/interested; it opens the card studio with that event preselected.
|
||||
- The mobile header wordmark truncates at 200% text instead of colliding with
|
||||
search/profile controls.
|
||||
- The frictionless first-run tip keeps one quiet “Find a crew” link.
|
||||
|
||||
---
|
||||
|
||||
🗓 Small clarity and recovery wins
|
||||
- Event pages now show the viewer's timezone under the local start/end time, so
|
||||
a shared event can't silently mean another city's clock.
|
||||
- The Gear Swap error state has a real Retry button instead of only an apology.
|
||||
|
||||
---
|
||||
|
||||
🛒 A smarter, safer Gear Swap
|
||||
- Save the current search/filters under a name and restore them with one tap.
|
||||
- Offer dialog gained quick note templates (discount, swap, pickup, shipping).
|
||||
- A collapsed safety guide now sits above the marketplace: meet in public,
|
||||
inspect the gear, keep deals in chat, never share keys, and read escrow terms
|
||||
before shipping.
|
||||
- Saved searches are device-local; no new server or tracking.
|
||||
|
||||
---
|
||||
|
||||
🧭 First run is one tap, not a to-do list
|
||||
- Removed the Add Spot / Join Crew / Wallet / Community CTAs from the first-run
|
||||
tip. A new skater now sees one optional "Near Me" button and "Got it"; the map
|
||||
is already populated and nothing needs setting up.
|
||||
- The fresh-signup checklist no longer fires a crew query, so it also costs less.
|
||||
|
||||
🛡 Inline safety actions
|
||||
- Report already lives on cards; Mute now sits beside it on spot, event, tour and
|
||||
gear cards, using the same NIP-51 mute list as the rest of the app.
|
||||
- Hidden content still explains itself to its author on the detail page.
|
||||
|
||||
---
|
||||
|
||||
🗺 The map remembers your view
|
||||
- The map restores the last center/zoom on return, so you don't re-find your
|
||||
neighbourhood every time.
|
||||
|
|
|
|||
BIN
resources/splash.png
Normal file
BIN
resources/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
42
scripts/backup-community-state.sh
Executable file
42
scripts/backup-community-state.sh
Executable 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
|
||||
|
|
@ -21,6 +21,9 @@
|
|||
* COMMUNITY_RELAY coordinator relay (default wss://relay.skate-spots.org)
|
||||
* COMMUNITY_POLL_MS poll interval (default 15000)
|
||||
* 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';
|
||||
|
|
@ -42,6 +45,18 @@ const STATE_PATH =
|
|||
process.env.COMMUNITY_STATE ?? '/var/lib/skatespots-community/state.json';
|
||||
const POLL_MS = Number(process.env.COMMUNITY_POLL_MS ?? 15_000);
|
||||
const AUTO_APPROVE = process.env.COMMUNITY_AUTO_APPROVE !== '0';
|
||||
const parsedMaxMembers = Number(process.env.COMMUNITY_MAX_MEMBERS ?? 250);
|
||||
const MAX_MEMBERS = Number.isFinite(parsedMaxMembers) && parsedMaxMembers >= 2
|
||||
? Math.floor(parsedMaxMembers)
|
||||
: 250;
|
||||
const parsedMaxDevices = Number(process.env.COMMUNITY_MAX_DEVICES_PER_PUBKEY ?? 5);
|
||||
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';
|
||||
|
||||
|
|
@ -119,10 +134,19 @@ async function joinRoom(): Promise<boolean> {
|
|||
return false;
|
||||
}
|
||||
|
||||
const approved = new Set<string>();
|
||||
// Keyed by the requester's key-package ref, not just their pubkey: one account
|
||||
// can legitimately join from a second device, and a consumed key package must
|
||||
// not be retried forever. Entries expire so a re-request after a long gap works.
|
||||
const APPROVAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const approved = new Map<string, number>();
|
||||
const lastAttempt = new Map<string, number>();
|
||||
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> {
|
||||
try {
|
||||
|
|
@ -149,23 +173,119 @@ async function cycle(): Promise<void> {
|
|||
|
||||
if (!AUTO_APPROVE) return;
|
||||
|
||||
const requests = await session.fetchJoinRequests(GID);
|
||||
const room = session.listGroups().find((group) => group.gid === GID);
|
||||
const memberCount = room?.members.length ?? 0;
|
||||
const requests = (await session.fetchJoinRequests(GID))
|
||||
.filter((request) => request.pk !== session.pubkey); // our own boot request
|
||||
if (requests.length !== lastPending && requests.length > 0) {
|
||||
log('pending join requests', requests.length);
|
||||
}
|
||||
lastPending = requests.length;
|
||||
|
||||
// A public room can be flooded by sybil key packages; MLS also gets more
|
||||
// expensive for every member. Stop auto-adding at a sane cap and leave the
|
||||
// queue for a human admin to review (raise the cap via env if needed).
|
||||
if (memberCount >= MAX_MEMBERS) {
|
||||
if (!capLogged) {
|
||||
capLogged = true;
|
||||
log('member cap reached', memberCount, '/', MAX_MEMBERS, '— leaving requests for admins');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (capLogged) {
|
||||
capLogged = false;
|
||||
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 (request.pk === session.pubkey) continue; // our own boot request
|
||||
if (approved.has(request.pk)) continue;
|
||||
const last = lastAttempt.get(request.pk) ?? 0;
|
||||
if (!/^[0-9a-f]{64}$/i.test(request.pk)) {
|
||||
log('skipping malformed join request');
|
||||
continue;
|
||||
}
|
||||
const currentRoom = session.listGroups().find((group) => group.gid === GID);
|
||||
if ((currentRoom?.members.length ?? 0) >= MAX_MEMBERS) {
|
||||
if (!capLogged) {
|
||||
capLogged = true;
|
||||
log('member cap reached', currentRoom?.members.length ?? 0, '/', MAX_MEMBERS, '— stopping this cycle');
|
||||
}
|
||||
break;
|
||||
}
|
||||
const devices = currentRoom?.members.filter((member) => member.pubkey === request.pk).length ?? 0;
|
||||
if (devices >= MAX_DEVICES_PER_PUBKEY) {
|
||||
if (!deviceCapLogged.has(request.pk)) {
|
||||
deviceCapLogged.add(request.pk);
|
||||
log('device cap reached for', short(request.pk), devices, '/', MAX_DEVICES_PER_PUBKEY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const approvalKey = request.kp_ref || request.pk;
|
||||
const approvedAt = approved.get(approvalKey) ?? 0;
|
||||
if (Date.now() - approvedAt < APPROVAL_TTL_MS) continue;
|
||||
const last = lastAttempt.get(approvalKey) ?? 0;
|
||||
if (Date.now() - last < 5 * 60_000) continue;
|
||||
try {
|
||||
// Prefer the exact key package the requester published. Falling back to
|
||||
// the pubkey keeps older coordinator responses working.
|
||||
try {
|
||||
await session.addMemberByIdentifier(GID, approvalKey);
|
||||
} catch (error) {
|
||||
if (request.kp_ref && request.kp_ref !== request.pk) {
|
||||
await session.addMemberByIdentifier(GID, request.pk);
|
||||
approved.add(request.pk);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
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
|
||||
} catch (error) {
|
||||
lastAttempt.set(request.pk, Date.now());
|
||||
log('could not approve', short(request.pk), error instanceof Error ? error.message : String(error));
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A stale request for an account already in the group cannot be added
|
||||
// again. Retire it instead of retrying forever (and let the user use
|
||||
// the Link-device flow for a second device).
|
||||
if (/already in the group/i.test(message)) {
|
||||
approved.set(approvalKey, Date.now());
|
||||
handled.push({ pk: request.pk, at: request.at });
|
||||
log('request already satisfied', short(request.pk));
|
||||
continue;
|
||||
}
|
||||
lastAttempt.set(approvalKey, Date.now());
|
||||
log('could not approve', short(request.pk), message);
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
|
||||
if (handled.length > 0) {
|
||||
try {
|
||||
await session.fetchJoinRequests(GID, handled);
|
||||
log('retired handled requests', handled.length);
|
||||
} catch (error) {
|
||||
log('could not retire handled requests', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the in-memory approval memory bounded.
|
||||
const cutoff = Date.now() - APPROVAL_TTL_MS;
|
||||
for (const [key, at] of approved) {
|
||||
if (at < cutoff) approved.delete(key);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message !== lastError) {
|
||||
|
|
@ -195,7 +315,13 @@ async function main(): Promise<void> {
|
|||
privateKeyHex: BOT_KEY,
|
||||
store: new FileCordnStore(STATE_PATH),
|
||||
});
|
||||
log('bot pubkey', session.pubkey, 'room', GID);
|
||||
log(
|
||||
'bot pubkey', session.pubkey, 'room', GID,
|
||||
'auto-approve', AUTO_APPROVE ? 'on' : 'off',
|
||||
'max', MAX_MEMBERS,
|
||||
'devices/account', MAX_DEVICES_PER_PUBKEY,
|
||||
'approvals/hour', MAX_APPROVALS_PER_HOUR,
|
||||
);
|
||||
|
||||
do {
|
||||
await cycle();
|
||||
|
|
|
|||
53
scripts/deploy-community-bot.sh
Executable file
53
scripts/deploy-community-bot.sh
Executable 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
|
||||
|
|
@ -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=""
|
||||
|
|
|
|||
11
scripts/install-community-backup-timer.sh
Executable file
11
scripts/install-community-backup-timer.sh
Executable 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
|
||||
62
scripts/make-android-splash.mjs
Normal file
62
scripts/make-android-splash.mjs
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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
108
scripts/publish-forgejo.sh
Executable 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
220
scripts/quota-proxy.mjs
Normal 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}`));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
10
scripts/systemd/skatespots-community-backup.service
Normal file
10
scripts/systemd/skatespots-community-backup.service
Normal 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
|
||||
10
scripts/systemd/skatespots-community-backup.timer
Normal file
10
scripts/systemd/skatespots-community-backup.timer
Normal 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
|
||||
28
scripts/systemd/skatespots-quota.service
Normal file
28
scripts/systemd/skatespots-quota.service
Normal 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
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { TrustBadge } from '@/components/TrustBadge';
|
|||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { SkateEvent } from '@/lib/types';
|
||||
import { EVENT_TYPE_LABELS } from '@/lib/types';
|
||||
|
|
@ -291,6 +292,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
|
|||
{t('placesUi.eventCard.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<MuteMenuItem pubkey={event.pubkey} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,32 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
const DISMISS_KEY = 'skatespots:first-run-dismissed';
|
||||
/** Set once, the first time the checklist has actually been shown. */
|
||||
/** Set once, the first time the tip has actually been shown. */
|
||||
const SEEN_KEY = 'skatespots:first-run-seen';
|
||||
/** The signup flow stamps this (ms). Only a *recent* signup counts as "first run". */
|
||||
const SIGNUP_KEY = 'signup_completed';
|
||||
const FRESH_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; // 2 days
|
||||
|
||||
/**
|
||||
* One-shot "get started" nudge shown on the map for freshly signed-up users.
|
||||
* One-shot, frictionless first-run tip.
|
||||
*
|
||||
* The crew step is dynamic: when the community already has crews the CTA is
|
||||
* "Join a crew" (→ /crews); when there are none it's "Start your crew"
|
||||
* (→ /create-crew). Logged-out visitors see the welcome banner instead.
|
||||
*
|
||||
* ⚠️ Gating (fixed 2026-10-12): this used to show to ANY logged-in user until
|
||||
* they tapped ✕, so returning skaters saw it forever. It now shows only when
|
||||
* (a) the account has a **recent `signup_completed` stamp** (set by the signup
|
||||
* flow — an imported key or an older account never matches) and (b) it has not
|
||||
* been shown before (`first-run-seen`). It stays visible for that one session
|
||||
* and never returns.
|
||||
* It deliberately does NOT ask a new skater to create a spot, crew or wallet.
|
||||
* The map is already populated; the only action is an optional "Near Me" (one
|
||||
* location prompt) and a dismiss. Any content creation is left to the user's
|
||||
* own intent, not onboarding.
|
||||
*/
|
||||
export function FirstRunChecklist() {
|
||||
export function FirstRunChecklist({ onFindNearby }: { onFindNearby?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const [dismissed, setDismissed] = useLocalStorage<boolean>(DISMISS_KEY, false);
|
||||
const [seen, setSeen] = useLocalStorage<boolean>(SEEN_KEY, false);
|
||||
const crews = useCrews(1);
|
||||
|
||||
// Was this a fresh in-app signup? Read once (a stable local fact).
|
||||
const [fresh] = useState(() => {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(SIGNUP_KEY) ?? 0);
|
||||
|
|
@ -45,8 +36,7 @@ export function FirstRunChecklist() {
|
|||
}
|
||||
});
|
||||
|
||||
// Capture `seen` at mount so marking it doesn't hide the checklist in the same
|
||||
// session — only the next one.
|
||||
// Capture at mount so marking it seen doesn't hide the tip in this session.
|
||||
const seenAtMount = useRef(seen);
|
||||
useEffect(() => {
|
||||
if (user && fresh && !dismissed && !seenAtMount.current) setSeen(true);
|
||||
|
|
@ -54,10 +44,6 @@ export function FirstRunChecklist() {
|
|||
|
||||
if (!user || dismissed || !fresh || seenAtMount.current) return null;
|
||||
|
||||
const hasCrews = (crews.data?.length ?? 0) > 0;
|
||||
const crewTo = hasCrews ? '/crews' : '/create-crew';
|
||||
const crewLabel = hasCrews ? t('mapUi.firstRunJoinCrew') : t('mapUi.firstRunStartCrew');
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto relative z-10 mx-3 mt-2 rounded-2xl border border-white/12 bg-black/85 p-3 backdrop-blur md:mx-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
|
@ -66,31 +52,39 @@ export function FirstRunChecklist() {
|
|||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('mapUi.dismiss')}
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-white/40 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ccff00] focus-visible:ring-offset-2 focus-visible:ring-offset-black"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-300">{t('mapUi.firstRunTip')}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Link to="/create-spot">
|
||||
<Button size="sm" className="h-8 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
{t('mapUi.firstRunAddSpot')}
|
||||
{onFindNearby && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onFindNearby();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="h-8 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{t('mapPage.nearMe')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={crewTo}>
|
||||
<Button size="sm" variant="outline" className="h-8 border-white/20 font-bold">
|
||||
{crewLabel}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/wallet">
|
||||
<Button size="sm" variant="outline" className="h-8 border-white/20 font-bold">
|
||||
{t('mapUi.firstRunWallet')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/community">
|
||||
<Button size="sm" variant="outline" className="h-8 border-white/20 font-bold">
|
||||
{t('communityUi.link')}
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="h-8 border-white/20 font-bold"
|
||||
>
|
||||
{t('mapUi.gotIt')}
|
||||
</Button>
|
||||
<Link
|
||||
to="/crews"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="self-center rounded text-xs font-medium text-gray-500 underline underline-offset-2 hover:text-gray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ccff00] focus-visible:ring-offset-2 focus-visible:ring-offset-black"
|
||||
>
|
||||
{t('mapUi.firstRunJoinCrew')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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="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}>
|
||||
|
|
|
|||
72
src/components/LazyZapDialog.tsx
Normal file
72
src/components/LazyZapDialog.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { itemDistanceKm } from '@/lib/sortOptions';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
|
||||
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
|
|
@ -288,6 +289,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<Flag className="h-4 w-4 mr-2" />
|
||||
{t('marketUi.card.reportListing')}
|
||||
</DropdownMenuItem>
|
||||
<MuteMenuItem pubkey={item.pubkey} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
|
|
|||
40
src/components/MuteMenuItem.tsx
Normal file
40
src/components/MuteMenuItem.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useMuteUser } from '@/hooks/useMuteUser';
|
||||
import { useUnmuteUser } from '@/hooks/useUnmuteUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** Inline mute action for content cards. Renders nothing when logged out. */
|
||||
export function MuteMenuItem({
|
||||
pubkey,
|
||||
className,
|
||||
}: {
|
||||
pubkey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: muted } = useMuteList();
|
||||
const mute = useMuteUser();
|
||||
const unmute = useUnmuteUser();
|
||||
if (!user || user.pubkey === pubkey) return null;
|
||||
|
||||
const isMuted = !!muted?.has(pubkey);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={mute.isPending || unmute.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isMuted) unmute.mutate(pubkey);
|
||||
else mute.mutate(pubkey);
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{isMuted ? <Volume2 className="mr-2 h-4 w-4" /> : <VolumeX className="mr-2 h-4 w-4" />}
|
||||
{isMuted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -433,7 +433,7 @@ export function NotificationBell({
|
|||
<MessageCircle className="h-4 w-4 text-[#ff0040]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-black text-white">{t('socialUi.dms.title')}</p>
|
||||
<p className="text-sm font-black text-white">{t('socialUi.bell.messagesAndCrews')}</p>
|
||||
<p className="text-xs text-gray-400">{t('socialUi.bell.dmsUnread', { count: unreadDms })}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-[#ff0040]" />
|
||||
|
|
|
|||
|
|
@ -127,6 +127,26 @@ export function OfferDialog({
|
|||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">
|
||||
{t('marketplaceExtrasUi.offerTemplates')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
t('marketplaceExtrasUi.offerDiscount'),
|
||||
t('marketplaceExtrasUi.offerSwap'),
|
||||
t('marketplaceExtrasUi.offerPickup'),
|
||||
t('marketplaceExtrasUi.offerShipping'),
|
||||
].map((template) => (
|
||||
<button
|
||||
key={template}
|
||||
type="button"
|
||||
onClick={() => setNote(template)}
|
||||
className="min-h-0 rounded-full border border-orange-500/25 bg-orange-500/5 px-2.5 py-1 text-[11px] text-orange-200 hover:bg-orange-500/15"
|
||||
>
|
||||
{template}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{t('marketUi.offer.messageLabel')}
|
||||
</Label>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
82
src/components/RoutePreloader.tsx
Normal file
82
src/components/RoutePreloader.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -22,6 +22,8 @@ import { TrustBadge } from '@/components/TrustBadge';
|
|||
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';
|
||||
|
|
@ -29,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 {
|
||||
|
|
@ -47,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).
|
||||
|
|
@ -244,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) => {
|
||||
|
|
@ -258,6 +276,7 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
|||
{t('mapUi.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<MuteMenuItem pubkey={spot.pubkey} className="text-gray-300" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
@ -273,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}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { ZapButton } from '@/components/ZapButton';
|
|||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||
|
||||
import type { SkateTour, TourCategory } from '@/lib/types';
|
||||
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
|
||||
|
|
@ -474,6 +475,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
{t('placesUi.eventCard.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<MuteMenuItem pubkey={tour.pubkey} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,6 +129,12 @@ export function CordnNativePanel({
|
|||
const queryClient = useQueryClient();
|
||||
const [roleBusy, setRoleBusy] = useState<string | null>(null);
|
||||
const isCommunity = scope === 'community';
|
||||
const waitingApprovalKey = isCommunity ? 'communityUi.waitingApproval' : 'cordnUi.waitingApproval';
|
||||
const waitingApprovalManualKey = isCommunity
|
||||
? 'communityUi.waitingApprovalManual'
|
||||
: 'cordnUi.waitingApprovalManual';
|
||||
const noInviteKey = isCommunity ? 'communityUi.noInvite' : 'cordnUi.noInvite';
|
||||
|
||||
const sourceCrew = crew as SkateCrew;
|
||||
const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event;
|
||||
const copy = {
|
||||
|
|
@ -160,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());
|
||||
|
|
@ -178,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);
|
||||
|
|
@ -213,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);
|
||||
|
|
@ -248,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.
|
||||
|
|
@ -319,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 () => {
|
||||
|
|
@ -338,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;
|
||||
|
|
@ -442,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);
|
||||
|
|
@ -451,8 +617,8 @@ export function CordnNativePanel({
|
|||
setAwaiting(false);
|
||||
return;
|
||||
}
|
||||
if (!awaiting) setNotice(t('cordnUi.noInvite'));
|
||||
}, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]);
|
||||
if (!awaiting) setNotice(t(noInviteKey));
|
||||
}, [checkNow, awaiting, t, noInviteKey, native.busy, user?.pubkey, native.groupId]);
|
||||
|
||||
// Keep the latest checker in a ref: the poll must not restart just because a
|
||||
// parent re-render gave the hook callbacks new identities.
|
||||
|
|
@ -558,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 })}
|
||||
|
|
@ -618,9 +814,32 @@ 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">
|
||||
{isCommunity && native.group && botPubkey && !native.group.members.some((member) => member.pubkey === botPubkey) && (
|
||||
<div role="status" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
{t('communityUi.botMissing')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{native.error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
|
|
@ -656,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
|
||||
|
|
@ -726,22 +974,40 @@ export function CordnNativePanel({
|
|||
</>
|
||||
) : native.interactiveHintVisible ? (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.waitingApprovalManual')}
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t(waitingApprovalManualKey)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('cordnUi.waitingApproval')}
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t(waitingApprovalKey)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{awaiting && !deviceLinkPending && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!native.ready || native.busy}
|
||||
onClick={() => void handleRequestJoin()}
|
||||
className="mx-auto block text-[11px] text-teal-300 underline underline-offset-2 hover:text-teal-200 disabled:opacity-50"
|
||||
>
|
||||
{t('cordnUi.requestAgain')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCommunity && (
|
||||
<p className="mx-auto max-w-sm text-center text-[11px] text-gray-500">
|
||||
{t('communityUi.secondDeviceHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice && <p className="text-center text-xs text-teal-300">{notice}</p>}
|
||||
|
||||
<AdvancedPanel
|
||||
groupRef={native.groupRef}
|
||||
coordinatorPubkey={native.coordinator.serverPubkey}
|
||||
keyPackageReady={native.keyPackageReady}
|
||||
lastCheckedAt={native.lastCheckedAt}
|
||||
copied={copied}
|
||||
onCopyRef={() => void copyRef()}
|
||||
/>
|
||||
|
|
@ -770,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'));
|
||||
});
|
||||
|
|
@ -797,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')}
|
||||
|
|
@ -942,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">
|
||||
|
|
@ -970,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>
|
||||
|
|
@ -1065,12 +1358,16 @@ export function CordnNativePanel({
|
|||
groupRef={native.groupRef}
|
||||
coordinatorPubkey={native.coordinator.serverPubkey}
|
||||
keyPackageReady={native.keyPackageReady || !!native.group}
|
||||
lastCheckedAt={native.lastCheckedAt}
|
||||
copied={copied}
|
||||
onCopyRef={() => void copyRef()}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
|
||||
<div
|
||||
id={isCommunity ? 'community-requests' : undefined}
|
||||
className="scroll-mt-24 space-y-2 rounded-md border border-white/10 bg-black/20 p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{t('cordnUi.requests')}
|
||||
|
|
@ -1097,7 +1394,7 @@ export function CordnNativePanel({
|
|||
key={`${req.pk}-${req.kp_ref}`}
|
||||
pubkey={req.pk}
|
||||
disabled={native.busy}
|
||||
onApprove={() => void native.approve(req.pk)}
|
||||
onApprove={() => void handleApprove(req)}
|
||||
botPubkey={botPubkey}
|
||||
botName={copy.botName}
|
||||
/>
|
||||
|
|
@ -1209,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;
|
||||
}) {
|
||||
|
|
@ -1251,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">
|
||||
|
|
@ -1273,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"
|
||||
|
|
@ -1293,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>
|
||||
</>
|
||||
|
|
@ -1406,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>
|
||||
|
|
@ -1439,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('{')
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
@ -83,8 +83,12 @@ export interface UseCordnNativeResult {
|
|||
loadRequests: () => Promise<void>;
|
||||
/** Background check that never toggles `busy` (for a tab/request badge). */
|
||||
loadRequestsQuiet: () => Promise<void>;
|
||||
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
|
||||
approve: (identifier: string) => Promise<boolean>;
|
||||
/** Founder/admin: add a requester by key-package ref, falling back to pubkey. */
|
||||
approve: (
|
||||
identifier: string,
|
||||
fallbackIdentifier?: string,
|
||||
handled?: { pk: string; at: number },
|
||||
) => Promise<boolean>;
|
||||
/** Founder/admin: remove a member leaf (device) from the group. */
|
||||
removeMember: (leafIndex: number) => Promise<boolean>;
|
||||
/** New device: publish a key package and return the QR payload. */
|
||||
|
|
@ -99,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
|
||||
|
|
@ -147,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);
|
||||
|
|
@ -205,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;
|
||||
|
|
@ -220,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 {
|
||||
|
|
@ -252,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());
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -484,12 +535,30 @@ export function useCordnNative({
|
|||
);
|
||||
|
||||
const approve = useCallback(
|
||||
async (identifier: string) => {
|
||||
async (identifier: string, fallbackIdentifier?: string, handled?: { pk: string; at: number }) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session || !group) return false;
|
||||
const approved = await run(async () => {
|
||||
// Prefer the requester's exact key package; older coordinators may only
|
||||
// resolve the pubkey, so keep that as a fallback.
|
||||
try {
|
||||
await session.addMemberByIdentifier(group.gid, identifier);
|
||||
const pending = await session.fetchJoinRequests(group.gid);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A stale request for an account already in the group: retire it
|
||||
// instead of leaving it in the queue forever.
|
||||
if (/already in the group/i.test(message)) {
|
||||
if (handled) await session.fetchJoinRequests(group.gid, [handled]);
|
||||
setRequests((current) => current.filter((request) => request.pk !== handled?.pk));
|
||||
return true;
|
||||
}
|
||||
if (!fallbackIdentifier || fallbackIdentifier === identifier) throw error;
|
||||
await session.addMemberByIdentifier(group.gid, fallbackIdentifier);
|
||||
}
|
||||
const pending = await session.fetchJoinRequests(
|
||||
group.gid,
|
||||
handled ? [handled] : [],
|
||||
);
|
||||
setRequests(pending);
|
||||
return true;
|
||||
});
|
||||
|
|
@ -527,6 +596,7 @@ export function useCordnNative({
|
|||
passiveSigner,
|
||||
groupRef,
|
||||
keyPackageReady,
|
||||
lastCheckedAt,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
47
src/hooks/useCordnRoomRead.ts
Normal file
47
src/hooks/useCordnRoomRead.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
41
src/hooks/useDmMute.ts
Normal file
41
src/hooks/useDmMute.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const KEY = 'skatespots:dm-muted-threads';
|
||||
const EVENT = 'skatespots:dm-muted-threads-change';
|
||||
|
||||
function read(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const parsed = raw ? JSON.parse(raw) as unknown : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Device-local per-thread mute for 1:1 DMs, group DMs and crew chat keys. */
|
||||
export function useDmMute() {
|
||||
const [keys, setKeys] = useState<string[]>(read);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<string[]>).detail;
|
||||
if (Array.isArray(detail)) setKeys(detail);
|
||||
};
|
||||
window.addEventListener(EVENT, onChange);
|
||||
return () => window.removeEventListener(EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
const write = useCallback((next: string[]) => {
|
||||
setKeys(next);
|
||||
try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* ignore */ }
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
|
||||
}, []);
|
||||
|
||||
const isMuted = useCallback((key: string | undefined) => !!key && keys.includes(key), [keys]);
|
||||
const toggle = useCallback((key: string) => {
|
||||
write(keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key].slice(-200));
|
||||
}, [keys, write]);
|
||||
|
||||
return { isMuted, toggle };
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
51
src/hooks/useUnmuteUser.ts
Normal file
51
src/hooks/useUnmuteUser.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { onlyVerified } from '@/lib/verifyEvents';
|
||||
import { removeLocalMute } from '@/lib/localMutes';
|
||||
import { MUTE_LIST_KIND } from '@/hooks/useMuteList';
|
||||
|
||||
/** Remove a pubkey from the viewer's NIP-51 kind-10000 mute list. */
|
||||
export function useUnmuteUser() {
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publish } = useNostrPublish();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (target: string) => {
|
||||
if (!user) throw new Error(t('walletUi.trust.loginToMute'));
|
||||
const events = onlyVerified(await nostr.query(
|
||||
[{ kinds: [MUTE_LIST_KIND], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
));
|
||||
const existing = (events[0]?.tags ?? [])
|
||||
.filter(([name]) => name === 'p')
|
||||
.map(([, value]) => value)
|
||||
.filter((value) => value && value !== target);
|
||||
await publish({
|
||||
kind: MUTE_LIST_KIND,
|
||||
content: '',
|
||||
tags: existing.map((pk) => ['p', pk]),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, target) => {
|
||||
removeLocalMute(target);
|
||||
queryClient.invalidateQueries({ queryKey: ['mute-list', user?.pubkey] });
|
||||
queryClient.invalidateQueries();
|
||||
toast({ title: t('walletUi.trust.unmuted'), description: t('walletUi.trust.unmutedDesc') });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('walletUi.trust.unmuteFailed'),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ const batch1 = {
|
|||
"newHere": "Ets nou aquí?",
|
||||
"welcomeDesc": "Descobreix spots de skate, esdeveniments i la comunitat.",
|
||||
"firstRunTitle": "Comença",
|
||||
"firstRunTip": "Toca qualsevol marcador per explorar. No cal configurar res — els spots a prop són a un toc.",
|
||||
"firstRunAddSpot": "Afegeix el teu primer spot",
|
||||
"firstRunJoinCrew": "Uneix-te a una colla",
|
||||
"firstRunStartCrew": "Crea la teva colla",
|
||||
|
|
@ -917,6 +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": "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.",
|
||||
|
|
@ -926,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",
|
||||
|
|
@ -957,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",
|
||||
|
|
@ -981,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",
|
||||
|
|
@ -1011,6 +1023,13 @@ const batch1 = {
|
|||
"notLoggedIn": "Inicia sessió per entrar al xat de la comunitat.",
|
||||
"noGroupAdmin": "Encara no s'ha creat la sala de la comunitat.",
|
||||
"noGroupMember": "Encara no ets a la sala de la comunitat.",
|
||||
"waitingApproval": "Esperant que el bot de la comunitat o un administrador ho aprovi…",
|
||||
"waitingApprovalManual": "Mode manual. Toca «Comprova si hi ha invitació» quan estigui aprovat.",
|
||||
"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": "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.",
|
||||
|
|
@ -1372,6 +1391,22 @@ const batch1 = {
|
|||
trust: "Confiança",
|
||||
noResults: "Cap resultat per a «{{query}}».",
|
||||
},
|
||||
marketplaceExtrasUi: {
|
||||
savedSearches: "Cerques desades",
|
||||
saveSearch: "Desa la cerca",
|
||||
searchName: "Posa nom a aquesta cerca…",
|
||||
deleteSearch: "Elimina la cerca desada",
|
||||
safetyTitle: "Compra i ven amb seguretat",
|
||||
safetyMeet: "Trobeu-vos en un lloc públic i reviseu el material abans de pagar.",
|
||||
safetyKeys: "No comparteixis mai frases seed ni claus privades. Mantén el tracte al xat de l'aplicació.",
|
||||
safetyEscrow: "Per als enviaments, llegeix primer les condicions de l'escrow: l'escrow Cashu és només per a l'app nativa i no està verificat.",
|
||||
safetyMore: "Llegeix les normes del mercat",
|
||||
offerTemplates: "Notes ràpides",
|
||||
offerDiscount: "Pots fer un 10 % de descompte?",
|
||||
offerSwap: "Consideraries un intercanvi?",
|
||||
offerPickup: "Puc recollir-ho aquesta setmana.",
|
||||
offerShipping: "Pots agrupar l'enviament?",
|
||||
},
|
||||
};
|
||||
|
||||
export default batch1;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const batch2 = {
|
|||
tabNews: "Notícies",
|
||||
tabCrews: "Crews",
|
||||
dmsUnread: "{{count}} missatge(s) xifrat(s) nou(s)",
|
||||
messagesAndCrews: "Missatges i xats de crew",
|
||||
checkingUpdates: "Buscant actualitzacions…",
|
||||
checkingTrades: "Buscant ofertes i intercanvis…",
|
||||
loadingDrops: "Carregant les últimes novetats…",
|
||||
|
|
@ -394,6 +395,7 @@ const batch2 = {
|
|||
newMessage: "Missatge nou",
|
||||
groups: "Grups",
|
||||
refresh: "Actualitza",
|
||||
privateRooms: "Sales privades",
|
||||
crews: "Crews",
|
||||
crewFounder: "Fundador",
|
||||
crewMember: "Membre",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,7 @@ const batch4 = {
|
|||
"eventDetail": {
|
||||
"aCrew": "una crew",
|
||||
"addToCalendar": "Afegeix al calendari",
|
||||
"eventPass": "Passi de l'esdeveniment",
|
||||
"backToEvents": "Torna als esdeveniments",
|
||||
"cancelled": "Aquest esdeveniment s'ha cancel·lat",
|
||||
"checkUpdate": ": consulta l'actualització",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -428,6 +428,10 @@ const batch6 = {
|
|||
mute: "Silencia",
|
||||
muted: "Silenciat",
|
||||
mutedDesc: "No veuràs el seu contingut.",
|
||||
unmute: "Deixa de silenciar",
|
||||
unmuted: "Silenciament desactivat",
|
||||
unmutedDesc: "El seu contingut tornarà a aparèixer.",
|
||||
unmuteFailed: "No s'ha pogut deixar de silenciar. Torna-ho a provar.",
|
||||
muteFailed: "No s'ha pogut silenciar. Torna-ho a provar.",
|
||||
loginToMute: "Inicia sessió per silenciar.",
|
||||
attributedNetwork: "De gent de la teva xarxa",
|
||||
|
|
@ -710,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",
|
||||
|
|
@ -829,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.",
|
||||
|
|
@ -958,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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const drift = {
|
|||
},
|
||||
"mapUi": {
|
||||
"firstRunTitle": "Začít",
|
||||
"firstRunTip": "Klepni na jakýkoli pin a objevuj. Nic není třeba nastavovat — okolní spoty máš na jeden dotek.",
|
||||
"firstRunAddSpot": "Přidej svůj první spot",
|
||||
"firstRunJoinCrew": "Přidej se k crew",
|
||||
"firstRunStartCrew": "Založ si crew",
|
||||
|
|
@ -50,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": {
|
||||
|
|
@ -81,6 +109,7 @@ const drift = {
|
|||
},
|
||||
"dms": {
|
||||
"refresh": "Obnovit",
|
||||
"privateRooms": "Soukromé místnosti",
|
||||
"crews": "Crew",
|
||||
"crewFounder": "Zakladatel",
|
||||
"crewMember": "Člen",
|
||||
|
|
@ -92,7 +121,8 @@ const drift = {
|
|||
"cordnGroupHint": "Volitelné: vlož odkaz na skupinu ze Staircase nebo Cordn web. Tvoje crew tak získá skutečný end-to-end MLS chat hostovaný na našem koordinátoru."
|
||||
},
|
||||
"bell": {
|
||||
"dmsUnread": "{{count}} nových šifrovaných zpráv"
|
||||
"dmsUnread": "{{count}} nových šifrovaných zpráv",
|
||||
"messagesAndCrews": "Zprávy a crew chaty"
|
||||
}
|
||||
},
|
||||
"settingsUi": {
|
||||
|
|
@ -113,6 +143,10 @@ const drift = {
|
|||
"mute": "Ztlumit",
|
||||
"muted": "Ztlumeno",
|
||||
"mutedDesc": "Nebudeš vídat jejich obsah.",
|
||||
"unmute": "Zrušit ztlumení",
|
||||
"unmuted": "Ztlumení zrušeno",
|
||||
"unmutedDesc": "Jejich obsah se znovu objeví.",
|
||||
"unmuteFailed": "Ztlumení se nepodařilo zrušit. Zkus to znovu.",
|
||||
"muteFailed": "Ztlumení se nezdařilo. Zkus to znovu.",
|
||||
"loginToMute": "Přihlas se, abys mohl ztlumit.",
|
||||
"attributedNetwork": "Od lidí ve tvé síti",
|
||||
|
|
@ -133,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ě",
|
||||
|
|
@ -256,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": {
|
||||
|
|
@ -275,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",
|
||||
|
|
@ -301,6 +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": "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.",
|
||||
|
|
@ -310,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ě",
|
||||
|
|
@ -338,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í",
|
||||
|
|
@ -362,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",
|
||||
|
|
@ -392,6 +482,13 @@ const drift = {
|
|||
"notLoggedIn": "Přihlas se, abys mohl vstoupit do komunitního chatu.",
|
||||
"noGroupAdmin": "Komunitní místnost ještě nebyla vytvořena.",
|
||||
"noGroupMember": "V komunitní místnosti zatím nejsi.",
|
||||
"waitingApproval": "Čeká se na schválení komunitním botem nebo administrátorem…",
|
||||
"waitingApprovalManual": "Ruční režim. Až bude schváleno, klepni na „Zkontrolovat pozvánku“.",
|
||||
"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": "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.",
|
||||
|
|
@ -450,6 +547,27 @@ const drift = {
|
|||
trust: "Důvěra",
|
||||
noResults: "Žádné výsledky pro „{{query}}“.",
|
||||
},
|
||||
marketplaceExtrasUi: {
|
||||
savedSearches: "Uložená hledání",
|
||||
saveSearch: "Uložit hledání",
|
||||
searchName: "Pojmenuj toto hledání…",
|
||||
deleteSearch: "Smazat uložené hledání",
|
||||
safetyTitle: "Nakupuj a prodávej bezpečně",
|
||||
safetyMeet: "Sejděte se na veřejném místě a vybavení si před platbou prohlédněte.",
|
||||
safetyKeys: "Nikdy nesdílej seed fráze ani soukromé klíče. Drž obchod v chatu v aplikaci.",
|
||||
safetyEscrow: "U zásilek si nejdřív přečti podmínky escrow: Cashu escrow je jen pro nativní aplikaci a je neověřené.",
|
||||
safetyMore: "Přečíst pravidla tržiště",
|
||||
offerTemplates: "Rychlé poznámky",
|
||||
offerDiscount: "Můžeš dát slevu 10 %?",
|
||||
offerSwap: "Nepřemýšlel bys o výměně?",
|
||||
offerPickup: "Tento týden si to můžu vyzvednout.",
|
||||
offerShipping: "Můžeš spojit dopravu?",
|
||||
},
|
||||
"placesUi": {
|
||||
"eventDetail": {
|
||||
"eventPass": "Vstupenka na akci"
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default drift;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const drift = {
|
|||
},
|
||||
mapUi: {
|
||||
firstRunTitle: "Loslegen",
|
||||
firstRunTip: "Tippe auf eine Markierung und leg los. Nichts einzurichten — Spots in der Nähe sind nur einen Tipp entfernt.",
|
||||
firstRunAddSpot: "Füge deinen ersten Spot hinzu",
|
||||
firstRunJoinCrew: "Einer Crew beitreten",
|
||||
firstRunStartCrew: "Starte deine Crew",
|
||||
|
|
@ -53,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: {
|
||||
|
|
@ -83,6 +111,7 @@ const drift = {
|
|||
},
|
||||
dms: {
|
||||
refresh: "Aktualisieren",
|
||||
privateRooms: "Private Räume",
|
||||
crews: "Crews",
|
||||
crewFounder: "Gründer",
|
||||
crewMember: "Mitglied",
|
||||
|
|
@ -95,6 +124,7 @@ const drift = {
|
|||
},
|
||||
bell: {
|
||||
dmsUnread: "{{count}} neue verschlüsselte Nachricht(en)",
|
||||
messagesAndCrews: "Nachrichten & Crew-Chats",
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
|
|
@ -115,6 +145,10 @@ const drift = {
|
|||
mute: "Stummschalten",
|
||||
muted: "Stummgeschaltet",
|
||||
mutedDesc: "Du siehst deren Inhalte nicht.",
|
||||
unmute: "Stummschaltung aufheben",
|
||||
unmuted: "Stummschaltung aufgehoben",
|
||||
unmutedDesc: "Ihre Inhalte werden wieder angezeigt.",
|
||||
unmuteFailed: "Stummschaltung konnte nicht aufgehoben werden. Versuch es erneut.",
|
||||
muteFailed: "Stummschalten fehlgeschlagen. Versuche es erneut.",
|
||||
loginToMute: "Melde dich an, um stummzuschalten.",
|
||||
attributedNetwork: "Von Personen in deinem Netzwerk",
|
||||
|
|
@ -136,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",
|
||||
|
|
@ -259,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: {
|
||||
|
|
@ -278,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",
|
||||
|
|
@ -303,6 +382,10 @@ const drift = {
|
|||
noGroupMember: "Du bist noch nicht im privaten Chat dieser Crew.",
|
||||
create: "Privaten Chat erstellen",
|
||||
requestJoin: "Um Beitritt bitten",
|
||||
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.",
|
||||
|
|
@ -312,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",
|
||||
|
|
@ -340,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",
|
||||
|
|
@ -364,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",
|
||||
|
|
@ -394,6 +484,13 @@ const drift = {
|
|||
notLoggedIn: "Melde dich an, um am Community-Chat teilzunehmen.",
|
||||
noGroupAdmin: "Der Community-Raum wurde noch nicht erstellt.",
|
||||
noGroupMember: "Du bist noch nicht im Community-Raum.",
|
||||
waitingApproval: "Warte auf die Freigabe durch den Community-Bot oder einen Admin…",
|
||||
waitingApprovalManual: "Manueller Modus. Tippe auf „Nach Einladung suchen“, sobald es freigegeben ist.",
|
||||
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: "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.",
|
||||
|
|
@ -452,6 +549,27 @@ const drift = {
|
|||
trust: "Vertrauen",
|
||||
noResults: "Keine Ergebnisse für „{{query}}“.",
|
||||
},
|
||||
marketplaceExtrasUi: {
|
||||
savedSearches: "Gespeicherte Suchen",
|
||||
saveSearch: "Suche speichern",
|
||||
searchName: "Diese Suche benennen…",
|
||||
deleteSearch: "Gespeicherte Suche löschen",
|
||||
safetyTitle: "Sicher kaufen & verkaufen",
|
||||
safetyMeet: "Trefft euch an einem öffentlichen Ort und prüft die Ausrüstung, bevor ihr bezahlt.",
|
||||
safetyKeys: "Teile niemals Seed-Phrasen oder private Schlüssel. Wickle den Deal im In-App-Chat ab.",
|
||||
safetyEscrow: "Bei Versandgeschäften lies zuerst die Escrow-Bedingungen: Cashu-Escrow gibt es nur in der nativen App, und sie ist nicht verifiziert.",
|
||||
safetyMore: "Marktplatz-Richtlinien lesen",
|
||||
offerTemplates: "Schnellnotizen",
|
||||
offerDiscount: "Gehen 10 % Rabatt?",
|
||||
offerSwap: "Würdest du über einen Tausch nachdenken?",
|
||||
offerPickup: "Ich kann es diese Woche abholen.",
|
||||
offerShipping: "Kannst du den Versand bündeln?",
|
||||
},
|
||||
placesUi: {
|
||||
eventDetail: {
|
||||
eventPass: "Event-Pass"
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default drift;
|
||||
|
|
|
|||
|
|
@ -349,6 +349,10 @@ const elBatch3 = {
|
|||
bannerImage: "Εικόνα banner",
|
||||
bannerDesc: "Μια φαρδιά εικόνα για το πάνω μέρος του προφίλ σου.",
|
||||
website: "Ιστοσελίδα",
|
||||
openToSponsorships: "Ανοιχτός σε χορηγίες",
|
||||
openToSponsorshipsHint: "Εμφανίζει μια κάρτα χορηγίας/συνεργασίας στο προφίλ σου. Τίποτα δεν κοινοποιείται μέχρι να σου στείλει κάποιος μήνυμα.",
|
||||
sponsorNote: "Σημείωση χορηγίας (προαιρετικό)",
|
||||
sponsorNotePlaceholder: "π.χ. Ανοιχτός σε συνεργάτες για σανίδες, παπούτσια, ταξίδια και εκδηλώσεις.",
|
||||
bitcoinTitle: "Bitcoin & Πληρωμές",
|
||||
bitcoinDesc: "Άφησε τους άλλους να σου κάνουν zap, tip και να σε πληρώνουν απευθείας — χωρίς μεσάζοντα.",
|
||||
lightningAddress: "Διεύθυνση Lightning",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -663,6 +663,8 @@ const elBatch5 = {
|
|||
manage: "Διαχείριση",
|
||||
admin: "Διαχειριστής",
|
||||
editProfile: "Επεξεργασία προφίλ",
|
||||
transportSelfAttestedTitle: "Αυτοδηλωμένο προφίλ μεταφορέα",
|
||||
transportSelfAttested: "Το SkateSpots δεν επαληθεύει άδειες οδήγησης, ασφαλίσεις, την καταλληλότητα των οχημάτων για κυκλοφορία ή άδειες εκμετάλλευσης. Χρησιμοποίησε το σήμα εμπιστοσύνης, τις κριτικές, τις εγγυήσεις και τη δική σου κρίση.",
|
||||
publishedTitle: "Είσαι live",
|
||||
publishedBody: "Το προφίλ σου δημοσιεύτηκε στο The Lab. Μοιράσου το ώστε να σε βρουν οι skaters.",
|
||||
manageRemove: "Αφαίρεση του προφίλ {{noun}} από το The Lab.",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const drift = {
|
|||
},
|
||||
mapUi: {
|
||||
firstRunTitle: "Ξεκίνα",
|
||||
firstRunTip: "Πάτησε οποιαδήποτε πινέζα για εξερεύνηση. Δεν χρειάζεται καμία ρύθμιση — τα κοντινά spot είναι ένα άγγιγμα μακριά.",
|
||||
firstRunAddSpot: "Πρόσθεσε το πρώτο spot",
|
||||
firstRunJoinCrew: "Μπες σε ένα crew",
|
||||
firstRunStartCrew: "Ξεκίνα το δικό σου crew",
|
||||
|
|
@ -55,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: {
|
||||
|
|
@ -85,6 +113,7 @@ const drift = {
|
|||
},
|
||||
dms: {
|
||||
refresh: "Ανανέωση",
|
||||
privateRooms: "Ιδιωτικά δωμάτια",
|
||||
crews: "Crews",
|
||||
crewFounder: "Ιδρυτής",
|
||||
crewMember: "Μέλος",
|
||||
|
|
@ -97,6 +126,7 @@ const drift = {
|
|||
},
|
||||
bell: {
|
||||
dmsUnread: "{{count}} νέα κρυπτογραφημένα μηνύματα",
|
||||
messagesAndCrews: "Μηνύματα & συνομιλίες crew",
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
|
|
@ -117,6 +147,10 @@ const drift = {
|
|||
mute: "Σίγαση",
|
||||
muted: "Σε σίγαση",
|
||||
mutedDesc: "Δεν θα βλέπεις το περιεχόμενό τους.",
|
||||
unmute: "Κατάργηση σίγασης",
|
||||
unmuted: "Η σίγαση καταργήθηκε",
|
||||
unmutedDesc: "Το περιεχόμενό τους θα εμφανιστεί ξανά.",
|
||||
unmuteFailed: "Δεν ήταν δυνατή η κατάργηση της σίγασης. Δοκίμασε ξανά.",
|
||||
muteFailed: "Δεν ήταν δυνατή η σίγαση. Δοκίμασε ξανά.",
|
||||
loginToMute: "Συνδέσου για σίγαση.",
|
||||
attributedNetwork: "Από άτομα στο δίκτυό σου",
|
||||
|
|
@ -138,6 +172,9 @@ const drift = {
|
|||
bucketFlaggedDesc: "Έμπιστα άτομα ανέφεραν αυτόν τον λογαριασμό.",
|
||||
reportedByTrusted: "Αναφέρθηκε από {{count}} έμπιστα άτομα",
|
||||
},
|
||||
stats: {
|
||||
transport: "Μεταφορές",
|
||||
},
|
||||
},
|
||||
trustUi: {
|
||||
alertsTitle: "Ειδοποιήσεις δικτύου",
|
||||
|
|
@ -261,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: {
|
||||
|
|
@ -280,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: "Ενημερώθηκε",
|
||||
|
|
@ -305,6 +384,10 @@ const drift = {
|
|||
noGroupMember: "Δεν είσαι ακόμη στην ιδιωτική συνομιλία αυτού του crew.",
|
||||
create: "Δημιουργία ιδιωτικής συνομιλίας",
|
||||
requestJoin: "Ζήτα συμμετοχή",
|
||||
requestAgain: "Νέο αίτημα",
|
||||
requestHandled: "Το αίτημα διεκπεραιώθηκε",
|
||||
copyCordnLink: "Αντιγραφή συνδέσμου Cordn",
|
||||
cordnWebCaveat: "Το Cordn web μπορεί να κολλήσει μετά τη σύνδεση· η συμμετοχή από την εφαρμογή είναι πιο αξιόπιστη.",
|
||||
checkInvite: "Έλεγχος πρόσκλησης",
|
||||
requested: "Το αίτημα στάλθηκε — ένας διαχειριστής του crew θα το εγκρίνει.",
|
||||
noInvite: "Δεν περιμένει ακόμη πρόσκληση. Ζήτα από έναν διαχειριστή να εγκρίνει το αίτημά σου.",
|
||||
|
|
@ -314,6 +397,8 @@ const drift = {
|
|||
requests: "Αιτήματα συμμετοχής",
|
||||
checkRequests: "Έλεγχος αιτημάτων",
|
||||
approve: "Προσθήκη",
|
||||
approveTitle: "Έγκριση αυτής της συσκευής;",
|
||||
approveDesc: "Προσθήκη του {{name}} σε αυτή την κρυπτογραφημένη αίθουσα; Αυτή η συσκευή θα μπορεί να διαβάζει νέα μηνύματα.",
|
||||
noRequests: "Δεν υπάρχουν εκκρεμή αιτήματα συμμετοχής.",
|
||||
inviteHint: "Μόνο εσύ είσαι σε αυτή τη συνομιλία μέχρι στιγμής — τα μέλη του crew μπορούν να πατήσουν «Ζήτα συμμετοχή» και μετά να τα εγκρίνεις εδώ.",
|
||||
memberCount: "{{count}} στην ομάδα",
|
||||
|
|
@ -342,7 +427,11 @@ const drift = {
|
|||
onboardingMember3: "Είστε μέσα — τα μηνύματα είναι κρυπτογραφημένα.",
|
||||
encryptedNote: "Μόνο τα μέλη του crew μπορούν να το διαβάσουν· ο συντονιστής απλώς προωθεί κρυπτογραφημένα μηνύματα.",
|
||||
advanced: "Για προχωρημένους",
|
||||
compareTitle: "Συνομιλία vs. ιδιωτική συνομιλία",
|
||||
compareChat: "Η συνομιλία είναι ένα τυπικό group DM του NIP-17. Λειτουργεί με άλλους πελάτες Nostr και δεν έχει συντονιστή.",
|
||||
comparePrivate: "Η ιδιωτική συνομιλία χρησιμοποιεί MLS: ισχυρότερο έλεγχο μελών και εμπιστευτικότητα προς τα εμπρός. Ο συντονιστής βλέπει μεταδεδομένα ομάδας και κρυπτοκείμενο, ποτέ το κείμενο των μηνυμάτων.",
|
||||
coordinator: "Συντονιστής",
|
||||
lastChecked: "Τελευταίος έλεγχος",
|
||||
keyPackage: "Πακέτο κλειδιών",
|
||||
keyPackageReady: "Έτοιμο σε αυτή τη συσκευή",
|
||||
keyPackageMissing: "Δεν έχει ρυθμιστεί σε αυτή τη συσκευή",
|
||||
|
|
@ -366,6 +455,7 @@ const drift = {
|
|||
qrTitle: "Σαρώστε για να μπείτε στην ιδιωτική συνομιλία",
|
||||
qrHint: "Τα μέλη του crew το σαρώνουν με την κάμερα του κινητού. Ο σύνδεσμος ανοίγει την καρτέλα Ιδιωτική συνομιλία.",
|
||||
sendFailed: "Δεν στάλθηκε — πατήστε Επανάληψη.",
|
||||
queued: "Σε ουρά σε αυτή τη συσκευή",
|
||||
sending: "Αποστολή…",
|
||||
sent: "Στάλθηκε",
|
||||
retry: "Επανάληψη",
|
||||
|
|
@ -396,6 +486,13 @@ const drift = {
|
|||
notLoggedIn: "Συνδέσου για να μπεις στη συνομιλία της κοινότητας.",
|
||||
noGroupAdmin: "Το δωμάτιο της κοινότητας δεν έχει δημιουργηθεί ακόμη.",
|
||||
noGroupMember: "Δεν είσαι ακόμη στο δωμάτιο της κοινότητας.",
|
||||
waitingApproval: "Αναμονή έγκρισης από το bot της κοινότητας ή έναν διαχειριστή…",
|
||||
waitingApprovalManual: "Χειροκίνητη λειτουργία. Πάτησε «Έλεγχος πρόσκλησης» μόλις εγκριθεί.",
|
||||
noInvite: "Δεν υπάρχει ακόμη πρόσκληση. Το bot εγκρίνει συνήθως μέσα σε ένα λεπτό — μετά πάτησε «Έλεγχος πρόσκλησης».",
|
||||
pendingRequests: "{{count}} αιτήματα συμμετοχής σε αναμονή",
|
||||
pendingRequestsDesc: "Το bot εγκρίνει συνήθως αυτόματα. Αν έχει κολλήσει, άνοιξε το δωμάτιο παρακάτω και πάτησε Έλεγχος αιτημάτων.",
|
||||
botMissing: "Το bot της κοινότητας δεν είναι αυτή τη στιγμή σε αυτό το δωμάτιο, οπότε ένας διαχειριστής θα εξετάσει τα αιτήματα συμμετοχής.",
|
||||
secondDeviceHint: "Είσαι ήδη μέλος σε άλλη συσκευή; Χρησιμοποίησε «Σύνδεση αυτής της συσκευής» για άμεση μεταβίβαση ή ζήτησε ξανά να συμμετάσχεις με αυτή τη συσκευή.",
|
||||
create: "Δημιουργία δωματίου κοινότητας",
|
||||
setupDeviceBody: "Αυτό δίνει σε αυτή τη συσκευή ένα κλειδί για το δωμάτιο της κοινότητας. Ένας διαχειριστής ή το bot το εγκρίνει μία φορά.",
|
||||
onboardingFounder1: "Δημιούργησε το δωμάτιο της κοινότητας.",
|
||||
|
|
@ -454,6 +551,27 @@ const drift = {
|
|||
trust: "Εμπιστοσύνη",
|
||||
noResults: "Κανένα αποτέλεσμα για «{{query}}».",
|
||||
},
|
||||
marketplaceExtrasUi: {
|
||||
savedSearches: "Αποθηκευμένες αναζητήσεις",
|
||||
saveSearch: "Αποθήκευση αναζήτησης",
|
||||
searchName: "Ονόμασε αυτή την αναζήτηση…",
|
||||
deleteSearch: "Διαγραφή αποθηκευμένης αναζήτησης",
|
||||
safetyTitle: "Αγόρασε και πούλα με ασφάλεια",
|
||||
safetyMeet: "Συναντηθείτε σε δημόσιο σημείο και ελέγξτε τον εξοπλισμό πριν πληρώσετε.",
|
||||
safetyKeys: "Ποτέ μη μοιράζεσαι seed phrases ή ιδιωτικά κλειδιά. Κράτα τη συναλλαγή στο chat της εφαρμογής.",
|
||||
safetyEscrow: "Για αποστολές, διάβασε πρώτα τους όρους του escrow: το Cashu escrow είναι μόνο για την εγγενή εφαρμογή και δεν είναι επαληθευμένο.",
|
||||
safetyMore: "Διάβασε τους κανόνες της αγοράς",
|
||||
offerTemplates: "Γρήγορες σημειώσεις",
|
||||
offerDiscount: "Μπορείς να κάνεις 10% έκπτωση;",
|
||||
offerSwap: "Θα σκεφτόσουν μια ανταλλαγή;",
|
||||
offerPickup: "Μπορώ να το παραλάβω αυτή την εβδομάδα.",
|
||||
offerShipping: "Μπορείς να συνδυάσεις τα μεταφορικά;",
|
||||
},
|
||||
placesUi: {
|
||||
eventDetail: {
|
||||
eventPass: "Πάσο εκδήλωσης"
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default drift;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue