Compare commits
No commits in common. "main" and "v1.7.36" have entirely different histories.
3
.gitignore
vendored
|
|
@ -52,8 +52,7 @@ stats.json
|
||||||
|
|
||||||
# Local/generated assets & incidental lockfiles (do not commit)
|
# Local/generated assets & incidental lockfiles (do not commit)
|
||||||
/assets/
|
/assets/
|
||||||
/resources/*
|
/resources/
|
||||||
!/resources/splash.png
|
|
||||||
/bun.lock
|
/bun.lock
|
||||||
cvm-server/bun.lock
|
cvm-server/bun.lock
|
||||||
push-service/bun.lock
|
push-service/bun.lock
|
||||||
|
|
|
||||||
387
AGENTS.md
|
|
@ -659,11 +659,7 @@ session with **no other memory of this project**. So:
|
||||||
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
|
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
|
||||||
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
|
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
|
||||||
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
|
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
|
||||||
**Form-page visibility (2026-10-29):** the assistant is deliberately hidden on
|
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
|
||||||
`/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
|
pulses forever — the `animate-ping` halo shows **only when an unread tip
|
||||||
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
||||||
(`isFullBleed` passed from `Layout`). The bubble keeps its **full size/glow**
|
(`isFullBleed` passed from `Layout`). The bubble keeps its **full size/glow**
|
||||||
|
|
@ -910,16 +906,6 @@ session with **no other memory of this project**. So:
|
||||||
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
|
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
|
||||||
aren't hard-coded**, so Marketplace/Mints (and any future section) are always
|
aren't hard-coded**, so Marketplace/Mints (and any future section) are always
|
||||||
reachable on mobile — don't re-hardcode only a subset.
|
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
|
- **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`**
|
boards/ramps/concrete. Content is a seed list in **`src/lib/diyGuides.ts`**
|
||||||
(`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` +
|
(`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` +
|
||||||
|
|
@ -1418,24 +1404,6 @@ session with **no other memory of this project**. So:
|
||||||
it in when known.
|
it in when known.
|
||||||
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
|
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
|
||||||
**`cordn`** field (app convention, documented in NIP.md — not a new kind).
|
**`cordn`** field (app convention, documented in NIP.md — not a new kind).
|
||||||
**Crew-owned coordinator kit (2026-10-31, install helper 2026-11-01):**
|
|
||||||
`deploy/cordn/install.sh` is the one-command installer (generates `.env`,
|
|
||||||
starts SQLite, prints nprofile/add link). `deploy/cordn/` remains the
|
|
||||||
production hand-off for crews: Docker Compose, SQLite volume, `.env` template,
|
|
||||||
backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM
|
|
||||||
relays; no inbound port, domain or TLS terminator is required for the
|
|
||||||
coordinator itself. The app still does **not** embed a coordinator. Settings →
|
|
||||||
Coordinator links to the kit; the crew's `coordinator` tag/operator remains
|
|
||||||
the adoption path. Keep the coordinator key separate from crew/admin keys.
|
|
||||||
Do not claim multi-coordinator failover; this is one trusted delivery service
|
|
||||||
with backups, not BFT.
|
|
||||||
**Custom coordinator routing (2026-11-01):** `useCordnNative` treats a crew's
|
|
||||||
own coordinator as authoritative. A custom URL without a service pubkey is
|
|
||||||
rejected instead of silently falling back to the SkateSpots coordinator. The
|
|
||||||
join watcher groups crews by coordinator pubkey/relay and polls each one; a
|
|
||||||
custom coordinator being offline does not stop notifications for other crews.
|
|
||||||
Device-link creation now shows an inline error/retry instead of a permanent
|
|
||||||
loading dialog.
|
|
||||||
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
|
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
|
||||||
http URL), **normalises a bare pointer to Cordn's canonical add-link** before
|
http URL), **normalises a bare pointer to Cordn's canonical add-link** before
|
||||||
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
|
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
|
||||||
|
|
@ -1799,17 +1767,6 @@ session with **no other memory of this project**. So:
|
||||||
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
|
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
|
||||||
captured with headless Chromium; regenerate with a CDP harness if the UI
|
captured with headless Chromium; regenerate with a CDP harness if the UI
|
||||||
changes materially).
|
changes materially).
|
||||||
- **Nsec storage hardening (2026-11-01):** `src/lib/nostrLogin.ts` replaces the
|
|
||||||
package login provider. Non-secret logins stay in localStorage; **nsec logins
|
|
||||||
are sessionStorage-only** and are also stripped from localStorage on the next
|
|
||||||
write. A tab reload keeps the nsec session; closing the browser/app requires
|
|
||||||
re-entry. Use Amber or a bunker to stay signed in. `src/lib/nostrLogin.test.tsx`
|
|
||||||
asserts raw nsec never lands in persistent localStorage. Do not import
|
|
||||||
`@nostrify/react/login` directly again — use `@/lib/nostrLogin`.
|
|
||||||
- **Direct-link boot rewrite (2026-11-01):** `main.tsx` now rewrites any
|
|
||||||
non-root pathname without a hash into `/#<path>` before React mounts, so
|
|
||||||
`/spot/...`, `/crew/...`, `/event/...`, `/listing/...` and cold native App
|
|
||||||
Links do not silently land on the map.
|
|
||||||
- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native
|
- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native
|
||||||
auth upgrades (owner picked these two; read-only/npub sessions were explicitly
|
auth upgrades (owner picked these two; read-only/npub sessions were explicitly
|
||||||
declined).
|
declined).
|
||||||
|
|
@ -2255,9 +2212,9 @@ session with **no other memory of this project**. So:
|
||||||
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
||||||
retries, then one full reload per session, so a chunk-load failure self-heals
|
retries, then one full reload per session, so a chunk-load failure self-heals
|
||||||
instead of showing the error boundary.
|
instead of showing the error boundary.
|
||||||
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**,
|
||||||
**156 tests**, build ok. The gate also type-checks the CVM server
|
**101 tests**, build ok. The gate also type-checks the CVM server
|
||||||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.79**.
|
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.5.4**.
|
||||||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||||||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||||||
|
|
@ -2405,7 +2362,7 @@ The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped o
|
||||||
### Android release build
|
### Android release build
|
||||||
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
|
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
|
||||||
- **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`.
|
- **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 **outside the repo** at `~/.config/skatespots/android/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 **`~/.config/skatespots/android/skatespots-release.jks`** (alias `skatespots`, mode 600). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`. Never put signing material back in the repo: Vite serves files by filesystem path, not by `.gitignore`.
|
- **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`.
|
- `android/` is **generated and gitignored**; recreate with `npm run android:init`.
|
||||||
- **Launch splash:** `resources/splash.png` is the orange brand wheel. Capacitor's
|
- **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`
|
generated drawables used a stock blue placeholder, so `scripts/make-android-splash.mjs`
|
||||||
|
|
@ -2444,95 +2401,6 @@ Rules:
|
||||||
- Any time the deploy output isn't verified by the smoke test, run
|
- Any time the deploy output isn't verified by the smoke test, run
|
||||||
`node scripts/smoke-test.mjs` manually before shipping.
|
`node scripts/smoke-test.mjs` manually before shipping.
|
||||||
|
|
||||||
### Trust, privacy and consistency invariants
|
|
||||||
- **No BFT / no relay quorum.** Nostr events are signed facts; relays are not
|
|
||||||
voters. Social feeds resolve conflicts locally, not by consensus.
|
|
||||||
- **Deterministic merge:** `src/lib/eventMerge.ts` is the single merge policy
|
|
||||||
(newest `created_at`, event-id tie-break, local tombstones). Do not add
|
|
||||||
"first relay wins" logic; relay arrival order must not change the result.
|
|
||||||
- **Trust boundaries are documented** in `docs/THREAT_MODEL.md`. Keep UI copy
|
|
||||||
honest: a confirmed trade means both parties signed; a payment proof means the
|
|
||||||
money leg was paid; escrow is time-locked, not arbitration.
|
|
||||||
- **No remote hotlinks for small brand assets:** OpenSats icons are local under
|
|
||||||
`public/images/` so rendering the footer does not leak the viewer's IP.
|
|
||||||
- **Android Auto Backup is off** (`android:allowBackup="false"` injected by
|
|
||||||
`scripts/inject-backup-policy.mjs`); identity/settings/escrow state stays on
|
|
||||||
the device and must be exported explicitly.
|
|
||||||
- **Image uploads re-encode decodable formats through canvas** to strip
|
|
||||||
EXIF/GPS before Blossom. GIF and HEIC/HEIF are passed through because they need
|
|
||||||
special handling; do not claim they are metadata-stripped.
|
|
||||||
- **No mandatory identity, deposit or global trust score.** Use warnings,
|
|
||||||
explicit consent and optional escrow at value boundaries.
|
|
||||||
|
|
||||||
### 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.79). 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
|
### 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`.
|
- **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.
|
- ⚠️ **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.
|
||||||
|
|
@ -2542,10 +2410,6 @@ Rules:
|
||||||
- Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`.
|
- Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`.
|
||||||
- ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
|
- ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
|
||||||
- **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`.
|
- **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`.
|
||||||
- **Community bot check is conservative:** a failed SSH probe now reports
|
|
||||||
“state unknown” and does **not** notify. Only an actual `inactive`/`failed`
|
|
||||||
systemd state is actionable. This stops transient SSH/DNS hiccups from
|
|
||||||
producing “community Cordn bot is unreachable” notifications.
|
|
||||||
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
|
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
|
||||||
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
|
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
|
||||||
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
|
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
|
||||||
|
|
@ -2793,203 +2657,6 @@ Rules:
|
||||||
- **1.7.35 (2026-10-29):** replaced the floating mobile “Links” pill over
|
- **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
|
the bottom nav with a slim always-visible footer strip. APK sha256
|
||||||
`d3cfdc3d…`, versionCode 10735. Web forced live at `5d40a67`.
|
`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`.
|
|
||||||
- **1.7.62 (2026-10-31):** 404 uses an in-app link; profile badge URLs are
|
|
||||||
sanitized; marketplace readiness only counts a usable lud16; duplicate
|
|
||||||
Korean picker entry hidden; beta source link points self-hosted. APK
|
|
||||||
sha256 `e746b18e…`, versionCode 10762. Web live at `c08d5d4`.
|
|
||||||
- **1.7.63 (2026-10-31):** deterministic event merge with event-id tie-break
|
|
||||||
and local deletion tombstones; EXIF/GPS stripping for decodable uploads;
|
|
||||||
Android Auto Backup disabled; local OpenSats icon; threat-model document.
|
|
||||||
APK sha256 `70e1dd1a…`, versionCode 10763. Web live at `3edef9c`.
|
|
||||||
- **1.7.64 (2026-10-31):** low-friction new-seller safety note beside trade
|
|
||||||
actions, using existing marketplace guidance; no new gate. APK sha256
|
|
||||||
`32d5cd35…`, versionCode 10764. Web live at `a44665f`.
|
|
||||||
- **1.7.65 (2026-10-31):** Settings relay-consistency diagnostic (profiles,
|
|
||||||
on demand, no merge); recent 90-day confirmed-trade count beside lifetime
|
|
||||||
reputation. APK sha256 `73684085…`, versionCode 10765. Web live at
|
|
||||||
`a813e1e`.
|
|
||||||
- **1.7.66 (2026-10-31):** high-value safety copy (no arbitration, escrow),
|
|
||||||
180-day half-life reputation weighting, and the unit-tested 2-of-3 escrow
|
|
||||||
policy primitive (not exposed in UI pending arbiter policy). APK sha256
|
|
||||||
`0ed775ed…`, versionCode 10766. Web live at `cc0545c`.
|
|
||||||
- **1.7.67 (2026-10-31):** crew-owned Cordn coordinator deployment kit
|
|
||||||
(`deploy/cordn/`: Compose, SQLite volume, env template, backup/restore,
|
|
||||||
runbook); persistent Docker command and links from Settings/Crew edit.
|
|
||||||
No embedded coordinator, no BFT claim. APK sha256 `ac7c9589…`, versionCode
|
|
||||||
10767. Web live at `c8f471b`.
|
|
||||||
- **1.7.68 (2026-10-31):** real ContextVM RPC coordinator health probe with
|
|
||||||
throwaway key, and optional crew coordinator operator field. APK sha256
|
|
||||||
`d8222789…`, versionCode 10768. Web live at `0fe82c2`.
|
|
||||||
- **1.7.69 (2026-10-31):** desktop status control is a compact status
|
|
||||||
emoji/label chip (no large outlined pill or permanent ping). APK sha256
|
|
||||||
`45add6a3…`, versionCode 10769. Web live at `226d0c3`.
|
|
||||||
- **1.7.70 (2026-10-31):** Amber inbox decrypt gate (explicit Unlock card),
|
|
||||||
10-minute expiry for redirect pending ops, and no plaintext/crypto results
|
|
||||||
left in browser storage. APK sha256 `be521c16…`, versionCode 10770. Web
|
|
||||||
live at `0859327`.
|
|
||||||
- **1.7.71 (2026-11-01):** desktop status control is a compact emoji-only
|
|
||||||
chip with a tooltip; mobile avatar duplicate online dot removed. APK sha256
|
|
||||||
`5334be69…`, versionCode 10771. Web live at `42ffcb1`.
|
|
||||||
- **1.7.72 (2026-11-01):** relay events are signature-verified before the
|
|
||||||
shared merge path; image/avatar requests and the site referrer policy use
|
|
||||||
no-referrer. APK sha256 `c2169cae…`, versionCode 10772. Web live at
|
|
||||||
`78741f9`.
|
|
||||||
- **1.7.73 (2026-11-01):** login dialog now has two adaptive methods:
|
|
||||||
Amber on Android, NIP-46 QR on desktop, with bunker paste as a secondary
|
|
||||||
path and no duplicate NIP-46 card. APK sha256 `bcd51861…`, versionCode
|
|
||||||
10773. Web live at `719a679`.
|
|
||||||
- **1.7.74 (2026-11-01):** one-command crew coordinator installer
|
|
||||||
(`deploy/cordn/install.sh`), persistent SQLite + nprofile output; Settings
|
|
||||||
shows the installer command. Phone host limitation documented. APK sha256
|
|
||||||
`da0914b9…`, versionCode 10774. Web live at `6ec5a9f`.
|
|
||||||
- **1.7.75 (2026-11-01):** crew coordinator routing is authoritative (no
|
|
||||||
silent fallback to SkateSpots); per-coordinator join notifications; device
|
|
||||||
link errors show retry. APK sha256 `530041f1…`, versionCode 10775. Web
|
|
||||||
live at `c21d1b0`.
|
|
||||||
- **1.7.76 (2026-11-01):** PWA 192/512/1024 icons, 512 maskable, 180 Apple
|
|
||||||
touch icon; signup QR print popup uses 100dvh. APK sha256 `175ab9d1…`,
|
|
||||||
versionCode 10776. Web live at `2e904c2`.
|
|
||||||
- **1.7.77 (2026-11-01):** wallet flow simplified: no brand directory,
|
|
||||||
hidden WebLN when absent, one connect CTA, generic help link. APK sha256
|
|
||||||
`47eb0a54…`, versionCode 10777. Web live at `b71ff5b`.
|
|
||||||
- **1.7.78 (2026-11-01):** native Wallet page/manager leads with built-in
|
|
||||||
Cashu wallet; slightly larger mobile nav labels. APK sha256 `661ef822…`,
|
|
||||||
versionCode 10778. Web live at `66928b2`.
|
|
||||||
- **1.7.79 (2026-11-01):** nsec logins are sessionStorage-only and stripped
|
|
||||||
from persistent localStorage; direct non-root paths rewrite into
|
|
||||||
HashRouter before mount. APK sha256 `8fdf506b…`, versionCode 10779. Web
|
|
||||||
live at `d0a593e`.
|
|
||||||
|
|
||||||
### Relay archive (dataset backup)
|
### Relay archive (dataset backup)
|
||||||
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
||||||
|
|
@ -3050,11 +2717,6 @@ Rules:
|
||||||
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
||||||
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
||||||
are **skipped by the deploy timer** → no Netlify credits spent.
|
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`
|
- **Dependency updates + vulnerability scan (2026-10-12):** `scripts/update-deps.sh`
|
||||||
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
|
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
|
||||||
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
|
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
|
||||||
|
|
@ -3298,11 +2960,6 @@ The community relay moved off the hosted relay.tools instance
|
||||||
cosmetic; unauthenticated reads work. Config backup:
|
cosmetic; unauthenticated reads work. Config backup:
|
||||||
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
|
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
|
||||||
`sudo docker compose up -d --force-recreate` from `~/newlay`.
|
`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
|
⚠️ **Content can silently miss the canonical relay:** the app publishes to
|
||||||
`getWriteRelays()` and resolves on the **first** relay that accepts, so a
|
`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
|
rate-limited community-relay write can leave a spot only on ditto/damus (this
|
||||||
|
|
@ -3331,9 +2988,7 @@ The community relay moved off the hosted relay.tools instance
|
||||||
dies, and posts to **ntfy.sh** when the problem set changes (deduped; also a
|
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`
|
"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
|
(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked at
|
||||||
`scripts/systemd/skatespots-health.{service,timer}`. **2026-10-30:** the host
|
`scripts/systemd/skatespots-health.{service,timer}`.
|
||||||
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
|
- **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`;
|
**`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
|
blobs stored at `/var/lib/newlay/data/blobs`, INSIDE the data volume so they
|
||||||
|
|
@ -3344,24 +2999,6 @@ The community relay moved off the hosted relay.tools instance
|
||||||
(`lib/imageFallback.ts`) includes it so reads fall back across all three.
|
(`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
|
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.
|
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
|
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
|
||||||
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
|
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
|
||||||
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =
|
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =
|
||||||
|
|
@ -3431,7 +3068,7 @@ The community relay moved off the hosted relay.tools instance
|
||||||
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
|
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
|
||||||
|
|
||||||
### Secrets — never commit or paste
|
### Secrets — never commit or paste
|
||||||
Signing material (`keystore.properties`, `*.jks`) lives **outside the repo** at `~/.config/skatespots/android/`; `*.jks`, `.env*`, and `android/` remain gitignored as a safety net. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager. Do not bind the Vite dev server to the LAN; use `adb reverse` for device testing.
|
`keystore.properties`, `*.jks`, `.env*`, and `android/` are gitignored. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|
@ -3530,16 +3167,6 @@ These components follow a consistent pattern using React's `forwardRef` and use
|
||||||
|
|
||||||
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
|
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
|
||||||
|
|
||||||
### Deferred: NIP-05 as a wallet address
|
|
||||||
**Do not set `lud16` to `name@skate-spots.org` until an LNURL-pay service exists.**
|
|
||||||
NIP-05 is a Nostr identity record; a Lightning address is a separate
|
|
||||||
`/.well-known/lnurlp/<name>` service. A Minibits-style wallet address needs the
|
|
||||||
NIP-05 server **and** an LNURL/mint/claim service (plus rate limits, backups,
|
|
||||||
privacy copy and a clear mint-trust model). Optional future paths:
|
|
||||||
self-hosted LNURL + Cashu mint, or a deliberate npub.cash/Minibits-style
|
|
||||||
integration. Until then keep NIP-05 as identity and use NWC/built-in Cashu;
|
|
||||||
never overwrite a user's custom `lud16`.
|
|
||||||
|
|
||||||
### Wallet Skills
|
### Wallet Skills
|
||||||
|
|
||||||
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.
|
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.
|
||||||
|
|
|
||||||
19
ESCROW.md
|
|
@ -77,26 +77,11 @@ token in a public event is spendable by anyone who sees it.
|
||||||
promptly, and lock windows should be generous.
|
promptly, and lock windows should be generous.
|
||||||
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
|
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
|
||||||
|
|
||||||
## Optional 2-of-3 primitive
|
|
||||||
|
|
||||||
`escrowP2PKOptions` now accepts an optional `arbiterPubkey` and can express the
|
|
||||||
NUT-11 policy used by a future 2-of-3 escrow:
|
|
||||||
|
|
||||||
- normal path: seller + arbiter, `n_sigs=2`;
|
|
||||||
- refund path: buyer + arbiter, `n_sigs_refund=2`;
|
|
||||||
- no arbiter: the current 1-of-1 seller/buyer policy.
|
|
||||||
|
|
||||||
The primitive is unit-tested, but **no UI or default flow uses it yet**. There is
|
|
||||||
no arbiter policy, bond, dispute process or appeal path, so exposing it would add
|
|
||||||
friction and false confidence. Do not enable it for real money until those are
|
|
||||||
defined and the flow is verified on-device.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
|
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
|
||||||
`escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
|
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`,
|
||||||
inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
|
locktime/refund policy, and the injectable `createEscrowToken` mint flow.
|
||||||
`createEscrowToken` mint flow.
|
|
||||||
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
|
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
|
||||||
checklist** below before treating as production. Do not use real money until
|
checklist** below before treating as production. Do not use real money until
|
||||||
every box passes.
|
every box passes.
|
||||||
|
|
|
||||||
19
ZAPSTORE.md
|
|
@ -156,18 +156,16 @@ Or use Android Studio: right-click `app` → New → Image Asset → import `pub
|
||||||
## Step 5 — Generate a signing keystore (one-time)
|
## Step 5 — Generate a signing keystore (one-time)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
install -d -m 700 ~/.config/skatespots/android
|
|
||||||
keytool -genkey -v \
|
keytool -genkey -v \
|
||||||
-keystore ~/.config/skatespots/android/skatespots-release.jks \
|
-keystore skatespots-release.jks \
|
||||||
-alias skatespots \
|
-alias skatespots \
|
||||||
-keyalg RSA \
|
-keyalg RSA \
|
||||||
-keysize 2048 \
|
-keysize 2048 \
|
||||||
-validity 10000 \
|
-validity 10000 \
|
||||||
-dname "CN=SkateSpots, OU=SkateSpots, O=SkateSpots, L=Bangkok, S=Bangkok, C=TH"
|
-dname "CN=SkateSpots, OU=SkateSpots, O=SkateSpots, L=Bangkok, S=Bangkok, C=TH"
|
||||||
chmod 600 ~/.config/skatespots/android/skatespots-release.jks
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Put the passwords in `~/.config/skatespots/android/keystore.properties` (mode 600), not in the repo. **Save the keystore file and passwords somewhere safe — you need them for every future release.**
|
**Save the keystore file and passwords somewhere safe — you need them for every future release.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -177,7 +175,18 @@ Put the passwords in `~/.config/skatespots/android/keystore.properties` (mode 60
|
||||||
npm run android:build
|
npm run android:build
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run android:build` injects the external signing config automatically. Do **not** pass signing passwords as `-Pandroid.injected...` command-line properties; that exposes them in shell history and process listings.
|
Or manually with signing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx cap sync android
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=../skatespots-release.jks \
|
||||||
|
-Pandroid.injected.signing.store.password=YOUR_STORE_PASSWORD \
|
||||||
|
-Pandroid.injected.signing.key.alias=skatespots \
|
||||||
|
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
APK output: `android/app/build/outputs/apk/release/app-release.apk`
|
APK output: `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Copy this file to .env, then generate a key:
|
|
||||||
# openssl rand -hex 32
|
|
||||||
# Keep .env mode 600 and back it up with the coordinator data.
|
|
||||||
CORDN_SERVER_PRIVATE_KEY=
|
|
||||||
|
|
||||||
# ContextVM relays the coordinator connects to. Add a second one if you want
|
|
||||||
# more reachability; the coordinator does not need an inbound port.
|
|
||||||
CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org
|
|
||||||
|
|
||||||
# Publicly announce the coordinator on its relays.
|
|
||||||
CORDN_ANNOUNCED=true
|
|
||||||
CORDN_SERVER_NAME=my-crew-coordinator
|
|
||||||
|
|
||||||
# Per-identity abuse protection. Raise for larger crews; keep it bounded.
|
|
||||||
CORDN_RATE_LIMIT_ENABLED=true
|
|
||||||
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
|
|
||||||
CORDN_RATE_LIMIT_BURST=80
|
|
||||||
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
|
|
||||||
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
# Crew-owned Cordn coordinator
|
|
||||||
|
|
||||||
This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It
|
|
||||||
is transport only: the coordinator sees ciphertext and routing metadata (who,
|
|
||||||
when, which group), never message plaintext. It can withhold or delay messages;
|
|
||||||
it cannot forge members' messages.
|
|
||||||
|
|
||||||
This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a
|
|
||||||
coordinator in the APK.
|
|
||||||
|
|
||||||
## What you need
|
|
||||||
|
|
||||||
- A small Linux server or always-on home machine with Docker + Compose.
|
|
||||||
- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator
|
|
||||||
connects out to its ContextVM relay(s).
|
|
||||||
- A domain is **not** required for the coordinator itself.
|
|
||||||
- A crew willing to trust the operator with availability and metadata.
|
|
||||||
|
|
||||||
## 1. Install
|
|
||||||
|
|
||||||
### Easiest: one command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
It checks Docker, generates the coordinator key, writes a private `.env`, starts
|
|
||||||
SQLite persistence, waits for the coordinator to announce, and prints the
|
|
||||||
`nprofile`/add link.
|
|
||||||
|
|
||||||
No inbound port, domain or TLS setup is required. The coordinator connects
|
|
||||||
outbound to the ContextVM relays in `CORDN_RELAY_URLS`.
|
|
||||||
|
|
||||||
### Manual: from this directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
chmod 600 .env
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set
|
|
||||||
`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public
|
|
||||||
relay. Set a useful `CORDN_SERVER_NAME`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
docker compose logs -f cordn
|
|
||||||
```
|
|
||||||
|
|
||||||
The server prints its coordinator public identity/add link. It may show an
|
|
||||||
`nprofile` or a `cordn.net/chat/coordinators?c=…` link.
|
|
||||||
|
|
||||||
If your build does not print an add link, derive the public key from the private
|
|
||||||
key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same
|
|
||||||
relay hints. Keep the private key on the server.
|
|
||||||
|
|
||||||
### Can I run it on a phone?
|
|
||||||
|
|
||||||
Not with this Docker kit. A phone can **administer** a coordinator, and Cordn's
|
|
||||||
own phone app is the hand-off path, but an always-on coordinator needs a machine
|
|
||||||
that stays awake and reachable. Android may suspend a foreground coordinator;
|
|
||||||
do not promise crew members that a phone is a reliable coordinator host.
|
|
||||||
|
|
||||||
For a crew that wants ownership without a VPS, an old Raspberry Pi or a
|
|
||||||
small always-on home server is the simplest reliable option. The coordinator
|
|
||||||
still needs no inbound port.
|
|
||||||
|
|
||||||
## 2. Adopt it in SkateSpots
|
|
||||||
|
|
||||||
Either:
|
|
||||||
|
|
||||||
- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or
|
|
||||||
- Crew edit → **Coordinator** → paste it and save; or
|
|
||||||
- scan the coordinator QR.
|
|
||||||
|
|
||||||
Use exactly the relay hints from the server. A mismatch produces an honest
|
|
||||||
“Not seen” health state even when the server is actually running.
|
|
||||||
|
|
||||||
The coordinator should show **Online** (or “Stale” if its heartbeat is old).
|
|
||||||
“Offline” means presence was not found, not necessarily that the service is
|
|
||||||
broken.
|
|
||||||
|
|
||||||
## 3. What the crew should know
|
|
||||||
|
|
||||||
- The coordinator can **censor or drop messages**.
|
|
||||||
- It cannot **read** MLS plaintext.
|
|
||||||
- It can see **who, when and which group**.
|
|
||||||
- It can disappear; local messages remain on members' devices, but delivery
|
|
||||||
pauses until the coordinator returns or the crew switches.
|
|
||||||
- The operator should **not** also become a crew admin automatically.
|
|
||||||
- A loss of the database is a loss of delivery history; back it up.
|
|
||||||
|
|
||||||
## 4. Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./backup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Backups land in `./backups/` as a compressed copy of the coordinator volume.
|
|
||||||
Store them off the server, encrypted.
|
|
||||||
|
|
||||||
## 5. Restore
|
|
||||||
|
|
||||||
Stop the coordinator, restore the archive into the `cordn-data` volume, start it
|
|
||||||
again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop cordn
|
|
||||||
./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
|
|
||||||
docker compose start cordn
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Upgrade
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The image is pinned only by the `latest` tag. If you need reproducible upgrades,
|
|
||||||
pin a digest and record it in your crew runbook.
|
|
||||||
|
|
||||||
## 7. Move to a new coordinator
|
|
||||||
|
|
||||||
MLS delivery state is tied to the coordinator’s database and identity. Before
|
|
||||||
switching:
|
|
||||||
|
|
||||||
1. back up the current coordinator;
|
|
||||||
2. export/retain the crew’s local chat state on members’ devices;
|
|
||||||
3. start the new coordinator;
|
|
||||||
4. paste its `nprofile` into the crew record;
|
|
||||||
5. have members reopen the private chat and re-establish delivery;
|
|
||||||
6. update the crew operator/notice.
|
|
||||||
|
|
||||||
Do not claim the switch is seamless unless the old and new coordinator state
|
|
||||||
have actually been migrated.
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
- Never commit `.env`; it contains the coordinator signing key.
|
|
||||||
- The coordinator key is **not** a crew admin key and must not be reused as one.
|
|
||||||
- Run it behind a trusted host/network; outbound relay traffic is metadata.
|
|
||||||
- Keep Docker and the image current.
|
|
||||||
- Do not expose an OpenCode or coordinator admin port publicly.
|
|
||||||
- This is **not** a BFT or multi-coordinator system. One coordinator is a
|
|
||||||
delivery service under the crew's social trust.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `docker-compose.yml` — persistent SQLite deployment.
|
|
||||||
- `.env.example` — required environment.
|
|
||||||
- `backup.sh` / `restore.sh` — volume snapshots.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Snapshot the Cordn coordinator data volume. Run from this directory.
|
|
||||||
set -eu
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
mkdir -p backups
|
|
||||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
|
||||||
out="cordn-$stamp.tgz"
|
|
||||||
docker run --rm \
|
|
||||||
-v cordn_cordn-data:/data:ro \
|
|
||||||
-v "$PWD/backups":/backup \
|
|
||||||
alpine:3.20 \
|
|
||||||
sh -c "tar czf /backup/$out -C /data ."
|
|
||||||
echo "Backup: $PWD/backups/$out"
|
|
||||||
echo "Store it off this server and keep it encrypted."
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
name: cordn
|
|
||||||
|
|
||||||
services:
|
|
||||||
cordn:
|
|
||||||
image: ghcr.io/cordn-msg/cordn:latest
|
|
||||||
container_name: cordn
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
CORDN_STORAGE_BACKEND: sqlite
|
|
||||||
CORDN_SQLITE_PATH: /data/cordn.sqlite
|
|
||||||
volumes:
|
|
||||||
- cordn-data:/data
|
|
||||||
# No inbound port is required: the coordinator connects *out* to the
|
|
||||||
# ContextVM relay(s) configured in CORDN_RELAY_URLS.
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cordn-data:
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# One-command crew coordinator installer.
|
|
||||||
#
|
|
||||||
# curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
|
|
||||||
#
|
|
||||||
# Creates a persistent Cordn coordinator with SQLite storage, starts it, and
|
|
||||||
# prints the nprofile/add link when the server announces itself. The coordinator
|
|
||||||
# connects OUT to ContextVM relays; it needs no inbound port or TLS domain.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")" 2>/dev/null || true
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
|
|
||||||
need docker
|
|
||||||
need openssl
|
|
||||||
docker compose version >/dev/null 2>&1 || { echo "Docker Compose v2 is required (docker compose)." >&2; exit 1; }
|
|
||||||
|
|
||||||
if [ ! -f docker-compose.yml ]; then
|
|
||||||
cat > docker-compose.yml <<'YAML'
|
|
||||||
name: cordn
|
|
||||||
services:
|
|
||||||
cordn:
|
|
||||||
image: ghcr.io/cordn-msg/cordn:latest
|
|
||||||
container_name: cordn
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
CORDN_STORAGE_BACKEND: sqlite
|
|
||||||
CORDN_SQLITE_PATH: /data/cordn.sqlite
|
|
||||||
volumes:
|
|
||||||
- cordn-data:/data
|
|
||||||
volumes:
|
|
||||||
cordn-data:
|
|
||||||
YAML
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
NAME="${CORDN_SERVER_NAME:-skatespots-crew}"
|
|
||||||
RELAYS="${CORDN_RELAY_URLS:-wss://relay.contextvm.org,wss://relay2.contextvm.org}"
|
|
||||||
KEY="$(openssl rand -hex 32)"
|
|
||||||
cat > .env <<EOF
|
|
||||||
CORDN_SERVER_PRIVATE_KEY=$KEY
|
|
||||||
CORDN_RELAY_URLS=$RELAYS
|
|
||||||
CORDN_ANNOUNCED=true
|
|
||||||
CORDN_SERVER_NAME=$NAME
|
|
||||||
CORDN_STORAGE_BACKEND=sqlite
|
|
||||||
CORDN_SQLITE_PATH=/data/cordn.sqlite
|
|
||||||
CORDN_RATE_LIMIT_ENABLED=true
|
|
||||||
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
|
|
||||||
CORDN_RATE_LIMIT_BURST=80
|
|
||||||
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
|
|
||||||
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
|
|
||||||
EOF
|
|
||||||
chmod 600 .env
|
|
||||||
echo "Generated .env (keep it private; it contains the coordinator key)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
echo "Waiting for the coordinator to announce itself…"
|
|
||||||
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
logs="$(docker compose logs --no-color 2>&1 || true)"
|
|
||||||
if printf '%s\n' "$logs" | grep -q 'nprofile'; then
|
|
||||||
printf '\n%s\n' "$logs" | grep -A12 -B2 'CORDN COORDINATOR'
|
|
||||||
echo
|
|
||||||
echo "Back up .env and the cordn-data volume before inviting the crew."
|
|
||||||
echo "Adopt it in SkateSpots: Settings → Coordinator, or Crew edit → Coordinator."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "The container started but no announcement appeared within 60s." >&2
|
|
||||||
echo "Inspect: docker compose logs --no-color" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Restore a Cordn coordinator backup into the cordn-data volume.
|
|
||||||
# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
|
|
||||||
set -eu
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; }
|
|
||||||
archive="$1"
|
|
||||||
[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; }
|
|
||||||
printf 'This replaces the current coordinator volume. Type "restore" to continue: '
|
|
||||||
read -r answer
|
|
||||||
[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; }
|
|
||||||
docker run --rm \
|
|
||||||
-v cordn_cordn-data:/data \
|
|
||||||
-v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \
|
|
||||||
alpine:3.20 \
|
|
||||||
sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data"
|
|
||||||
echo "Restored $(basename "$archive")."
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
# 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.83):
|
|
||||||
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.83/SkateSpots-1.7.83.apk>
|
|
||||||
- SHA-256:
|
|
||||||
`1fe70588adea237045b117ccdf013811b87a8368caf6b1148a7ab7da0a6250c3`
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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,104 +0,0 @@
|
||||||
# SkateSpots threat model
|
|
||||||
|
|
||||||
This is the short, technical version of what SkateSpots can and cannot guarantee.
|
|
||||||
It exists so product copy, support answers and future code do not accidentally
|
|
||||||
promise consensus, arbitration or delivery verification that the system does not
|
|
||||||
have.
|
|
||||||
|
|
||||||
## The model in one sentence
|
|
||||||
|
|
||||||
**SkateSpots verifies signed facts, keeps a local view, and uses social/economic
|
|
||||||
signals at value boundaries. It does not run a consensus protocol.**
|
|
||||||
|
|
||||||
Nostr relays are not voters. A relay can withhold, censor, reorder or serve a
|
|
||||||
stale event, but it cannot forge a valid signature. Different clients can
|
|
||||||
therefore see different snapshots of the network without either side being
|
|
||||||
"wrong" in a Byzantine-consensus sense.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Nostr identity keys and local signer access.
|
|
||||||
- Local cache, drafts, pending posts and local deletion tombstones.
|
|
||||||
- Crew membership and Cordn group membership.
|
|
||||||
- Marketplace listings, offers, trade attestations and reputation.
|
|
||||||
- Cashu proofs and escrow tokens.
|
|
||||||
- Device storage and any local backup/export.
|
|
||||||
|
|
||||||
## Actors and trust boundaries
|
|
||||||
|
|
||||||
| Actor | Can do | Cannot do |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Author | Sign events with their key | Forge another author's signature |
|
|
||||||
| Relay | Withhold, censor, reorder, serve stale or conflicting signed events | Forge a valid event; guarantee availability |
|
|
||||||
| Cordn coordinator | Route opaque group ciphertext; drop or delay messages | Read plaintext; forge an author proof |
|
|
||||||
| Cashu mint | Issue/redeem proofs, enforce locktime, freeze or become insolvent | Spend a correctly locked P2PK proof without a valid witness |
|
|
||||||
| Founder/admin | Modify the crew record, moderate crew content | Forge members' signatures; guarantee global agreement |
|
|
||||||
| Buyer/seller | Publish attestations and payment/receipt claims | Make a false claim true by signing it |
|
|
||||||
| WebView / host | Run the client | Be assumed trustworthy after device compromise |
|
|
||||||
|
|
||||||
## Guarantees
|
|
||||||
|
|
||||||
- **Authenticity:** a valid Schnorr signature proves which key signed an event,
|
|
||||||
not that the content is true.
|
|
||||||
- **Availability:** the client can use cached data and multiple relays; there is
|
|
||||||
no guarantee that every relay has every event.
|
|
||||||
- **Local determinism:** when two valid versions of an addressable or replaceable
|
|
||||||
event exist, the client chooses the newest `created_at`, with an event-id
|
|
||||||
tie-breaker. This is deterministic, not globally agreed.
|
|
||||||
- **Deletion:** NIP-09 is a request. Local tombstones prevent a deleted item from
|
|
||||||
flashing back on that device; relays may ignore the request.
|
|
||||||
- **Payments:** a Lightning preimage proves the invoice was paid. It does not
|
|
||||||
prove goods were delivered.
|
|
||||||
- **Escrow:** a time-locked Cashu P2PK token protects the buyer after the lock
|
|
||||||
expires and lets the seller redeem on delivery. It is not arbitration and does
|
|
||||||
not prove delivery.
|
|
||||||
- **Reputation:** reciprocal signed attestations, distinct partners and
|
|
||||||
web-of-trust weighting are evidence. Two colluding accounts can still create a
|
|
||||||
fake trade; the app must not present that as proof of goods.
|
|
||||||
|
|
||||||
## Non-guarantees
|
|
||||||
|
|
||||||
- No global ordering or consensus over relays.
|
|
||||||
- No protection from a compromised identity key, signer or device.
|
|
||||||
- No guaranteed delivery or carrier verification.
|
|
||||||
- No neutral dispute resolution.
|
|
||||||
- No guarantee that every relay honours deletion or moderation.
|
|
||||||
- No guarantee that a mint remains solvent, online or P2PK-compatible.
|
|
||||||
- No anonymity guarantee: public events, relays, image hosts and map/weather
|
|
||||||
providers may observe network metadata.
|
|
||||||
- No guarantee that a Cordn coordinator cannot censor a group. It cannot read or
|
|
||||||
forge messages, but it can drop them.
|
|
||||||
|
|
||||||
## Cordn specifically
|
|
||||||
|
|
||||||
The current Cordn group scheme is NIP-44 group delivery with a signed author
|
|
||||||
proof inside the ciphertext. It is not full MLS yet: it does not provide MLS
|
|
||||||
forward secrecy or post-compromise security. A coordinator cannot read the
|
|
||||||
message or impersonate the author, but it can refuse to relay it.
|
|
||||||
|
|
||||||
## Marketplace safety rules
|
|
||||||
|
|
||||||
- Prefer escrowed or in-person deals for high-value trades.
|
|
||||||
- Never send a plain, unlocked Cashu token in a public event.
|
|
||||||
- Treat "confirmed trade" as "both parties signed", not "goods verified".
|
|
||||||
- Treat "payment verified" as "the money leg is verified".
|
|
||||||
- A new account with no distinct confirmed partners is a warning, not a ban.
|
|
||||||
- Cash, barter and off-platform settlement have no on-chain proof; use
|
|
||||||
reputation and judgement.
|
|
||||||
|
|
||||||
## Privacy boundaries
|
|
||||||
|
|
||||||
- Public Nostr events are public forever in practice; deletion is best-effort.
|
|
||||||
- DMs and Cordn ciphertext are encrypted, but metadata (who, when, which group)
|
|
||||||
may be visible to relays/coordinators depending on the feature.
|
|
||||||
- Images are public once uploaded; strip metadata before upload and do not put
|
|
||||||
addresses in public listing images.
|
|
||||||
- Self-hosted infrastructure avoids third-party analytics, but network observers
|
|
||||||
can still see traffic to relays, image hosts, map tiles and weather services.
|
|
||||||
|
|
||||||
## What would change this model
|
|
||||||
|
|
||||||
A real consensus or arbitration layer would need a separate design with explicit
|
|
||||||
participants, quorum, liveness, slashing/appeals and privacy trade-offs. It
|
|
||||||
should not be smuggled in as "trust" copy or a global score. Until then:
|
|
||||||
**signed facts, local merge, explicit boundaries, no fake guarantees.**
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Small correctness pass: 404 navigation stays in-app, profile badge URLs are sanitized, marketplace readiness only counts a usable Lightning address, and the duplicate Korean picker entry is hidden.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Deterministic relay merging with a stable event-id tie-break and local deletion tombstones; EXIF/GPS stripping on upload; Android cloud backup disabled; local OpenSats icon; added the trust-model document.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Listings from a new seller with no confirmed trading partner now show a compact safety note beside the trade actions: meet in public, prefer escrow for shipped deals, and never pay outside the app's rails.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Settings now has an on-demand relay consistency check for profile snapshots; it reports differences without merging data. Trade reputation also shows how many mutually-confirmed trades happened in the last 90 days, alongside lifetime counts.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
High-value listings now show the safety note including the no-arbitration reminder; reputation uses a 180-day half-life and recent 90-day count; the 2-of-3 escrow policy primitive is unit-tested but not exposed until an arbiter policy exists.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Added a production crew-owned Cordn coordinator kit: Docker Compose with SQLite persistence, an env template, backup/restore scripts, and a runbook. The coordinator needs only outbound relay access; Settings and crew edit link to the kit. The app still does not embed a coordinator.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
The desktop header status control is now a compact chip showing your current status emoji and label, or a neutral Status affordance when none is set. No permanent ping or large outlined pill.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Amber users now unlock encrypted conversations explicitly instead of triggering a decrypt storm on Messages. Amber redirect requests expire after 10 minutes, and plaintext/crypto results are no longer persisted in browser storage.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
The desktop status control is now a compact emoji-only round chip with a tooltip, so it cannot collide with profile controls. Removed the duplicate online dot from the mobile avatar.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Relay events are now verified before the shared content merge, and images/avatars use a no-referrer policy to reduce third-party metadata leaks.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
The login dialog now shows two adaptive methods: Amber on Android, or NIP-46 QR pairing on desktop. Bunker paste is a secondary path, and the duplicate NIP-46 card was removed.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Added a one-command crew coordinator installer that generates the key, starts persistent SQLite storage, and prints the coordinator link. Settings now shows the installer command; no inbound port is required.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Crew-owned coordinators are now authoritative for their crews, join-request notifications poll each coordinator independently, and device-link failures show an inline retry instead of a stuck loading dialog.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Added proper 192px and 512px PWA icons plus a 512px maskable icon and a 180px Apple touch icon. The signup recovery-code print popup now respects the dynamic mobile viewport.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Simplified wallet setup: removed the brand directory and duplicate Add Wallet action, hide WebLN unless detected, and added one neutral connection path.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
The native Wallet page and wallet manager now lead with the built-in SkateSpots ecash wallet, with an existing Lightning wallet as the secondary path. Bottom navigation labels are slightly larger.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Raw nsec logins are now session-only and removed from persistent browser storage. Direct links to spots, crews, events and listings now enter the app router correctly, including cold native App Links.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Marketplace UX overhaul: URL-synced filters, mobile filter/action sheets, real link cards, normalized price sorting, draft autosave, live preview and photo cover controls. Show More adds listings beyond the first 200.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Grantmaker inquiry: shareable grants tab, progressive form, review/copy fallback, website validation and clearer funding priorities. Community invite uses the canonical /community link; retired Nostr relay removed from active configs.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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.
|
|
||||||
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -1 +0,0 @@
|
||||||
Local-first skate map, crews, events and marketplace.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
SkateSpots
|
|
||||||
41
index.html
|
|
@ -1,10 +1,9 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" style="background:#050505">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
<title>SkateSpots - Decentralized Skate Map & Community</title>
|
||||||
<title>SkateSpots — Open-sourced Skate Community App</title>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Content-Security-Policy (defense-in-depth) — shipped in the HTML so it
|
Content-Security-Policy (defense-in-depth) — shipped in the HTML so it
|
||||||
|
|
@ -45,11 +44,11 @@
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
|
||||||
<!-- App Icons -->
|
<!-- App Icons -->
|
||||||
<link rel="icon" type="image/png" href="/images/brand-192.png">
|
<link rel="icon" type="image/png" href="/images/icon-1024.png">
|
||||||
<link rel="apple-touch-icon" href="/images/brand-180.png">
|
<link rel="apple-touch-icon" href="/images/icon-1024.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="/images/icon-1024.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/icon-1024.png">
|
||||||
<link rel="apple-touch-startup-image" href="/images/brand-1024.png">
|
<link rel="apple-touch-startup-image" href="/images/icon-1024.png">
|
||||||
|
|
||||||
<!-- Theme Color -->
|
<!-- Theme Color -->
|
||||||
<meta name="theme-color" content="#ff0040">
|
<meta name="theme-color" content="#ff0040">
|
||||||
|
|
@ -58,27 +57,21 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="SkateSpots">
|
<meta name="apple-mobile-web-app-title" content="SkateSpots">
|
||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="An open-sourced skate community app for map, crews, events, gear and more.">
|
<meta name="description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant.">
|
||||||
<meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, open source, bitcoin, lightning, zaps">
|
<meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, decentralized, bitcoin, lightning, zaps">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="SkateSpots">
|
<meta property="og:title" content="SkateSpots - Decentralized Skate Map & Community">
|
||||||
<meta property="og:title" content="SkateSpots — Open-sourced Skate Community App">
|
<meta property="og:description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr.">
|
||||||
<meta property="og:description" content="Map, crews, events, gear and more — an open-sourced skate community app.">
|
<meta property="og:image" content="https://skate-spots.org/images/icon-1024.png">
|
||||||
<meta property="og:image" content="https://skate-spots.org/images/og-card-v2.png">
|
|
||||||
<meta property="og:image:type" content="image/png">
|
|
||||||
<meta property="og:image:width" content="1200">
|
|
||||||
<meta property="og:image:height" content="630">
|
|
||||||
<meta property="og:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
|
|
||||||
<meta property="og:url" content="https://skate-spots.org">
|
<meta property="og:url" content="https://skate-spots.org">
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="SkateSpots — Open-sourced Skate Community App">
|
<meta name="twitter:title" content="SkateSpots - Decentralized Skate Map">
|
||||||
<meta name="twitter:description" content="Map, crews, events and gear for the skate community.">
|
<meta name="twitter:description" content="Discover skate spots, find coaches, join events on Nostr.">
|
||||||
<meta name="twitter:image" content="https://skate-spots.org/images/og-card-v2.png">
|
<meta name="twitter:image" content="https://skate-spots.org/images/icon-1024.png">
|
||||||
<meta name="twitter:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
|
|
||||||
|
|
||||||
<!-- Leaflet CSS is self-hosted (imported in main.tsx) — no unpkg/CDN. -->
|
<!-- Leaflet CSS is self-hosted (imported in main.tsx) — no unpkg/CDN. -->
|
||||||
<!-- Warm up the connections used on almost every visit: our relay + media. -->
|
<!-- Warm up the connections used on almost every visit: our relay + media. -->
|
||||||
|
|
@ -87,14 +80,14 @@
|
||||||
<link rel="preconnect" href="https://relay.skate-spots.org" crossorigin>
|
<link rel="preconnect" href="https://relay.skate-spots.org" crossorigin>
|
||||||
<link rel="preconnect" href="https://blossom.skate-spots.org" crossorigin>
|
<link rel="preconnect" href="https://blossom.skate-spots.org" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
<body style="background:#050505">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
|
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.
|
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
|
||||||
-->
|
-->
|
||||||
<script src="/boot-guard.js?v=20261030"></script>
|
<script src="/boot-guard.js"></script>
|
||||||
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
122
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.83",
|
"version": "1.7.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.83",
|
"version": "1.7.36",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
|
|
@ -89,7 +89,6 @@
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"ecpair": "^3.0.0",
|
"ecpair": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
|
@ -121,6 +120,7 @@
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"websocket-ts": "^2.2.1",
|
"websocket-ts": "^2.2.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -2946,6 +2946,12 @@
|
||||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@protobufjs/inquire": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/path": {
|
"node_modules/@protobufjs/path": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||||
|
|
@ -6006,6 +6012,15 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|
@ -7039,6 +7054,19 @@
|
||||||
"@capacitor/core": ">=8.0.0"
|
"@capacitor/core": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||||
|
|
@ -7185,6 +7213,15 @@
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -7298,6 +7335,18 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -8572,6 +8621,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
|
@ -10868,9 +10926,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.6.5",
|
"version": "7.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
|
||||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -10880,6 +10938,7 @@
|
||||||
"@protobufjs/eventemitter": "^1.1.1",
|
"@protobufjs/eventemitter": "^1.1.1",
|
||||||
"@protobufjs/fetch": "^1.1.1",
|
"@protobufjs/fetch": "^1.1.1",
|
||||||
"@protobufjs/float": "^1.0.2",
|
"@protobufjs/float": "^1.0.2",
|
||||||
|
"@protobufjs/inquire": "^1.1.2",
|
||||||
"@protobufjs/path": "^1.1.2",
|
"@protobufjs/path": "^1.1.2",
|
||||||
"@protobufjs/pool": "^1.1.0",
|
"@protobufjs/pool": "^1.1.0",
|
||||||
"@protobufjs/utf8": "^1.1.1",
|
"@protobufjs/utf8": "^1.1.1",
|
||||||
|
|
@ -12214,6 +12273,18 @@
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackblur-canvas": {
|
"node_modules/stackblur-canvas": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
|
@ -14063,6 +14134,24 @@
|
||||||
"bs58check": "^4.0.0"
|
"bs58check": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
@ -14164,6 +14253,27 @@
|
||||||
"node": ">=14.17.0"
|
"node": ">=14.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|
|
||||||
12
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.83",
|
"version": "1.7.36",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.org",
|
"homepage": "https://skate-spots.org",
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
"check:i18n": "node scripts/check-i18n.mjs",
|
"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!'",
|
"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",
|
"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-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.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-backup-policy.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:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"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-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.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",
|
"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",
|
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||||
"translation:matrix": "node scripts/translation-matrix.mjs"
|
"translation:matrix": "node scripts/translation-matrix.mjs"
|
||||||
|
|
@ -111,7 +111,6 @@
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"ecpair": "^3.0.0",
|
"ecpair": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
|
@ -143,6 +142,7 @@
|
||||||
"tinyld": "^1.3.4",
|
"tinyld": "^1.3.4",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"websocket-ts": "^2.2.1",
|
"websocket-ts": "^2.2.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -184,7 +184,7 @@
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tanstack/query-core": "$@tanstack/react-query",
|
"@tanstack/query-core": "$@tanstack/react-query",
|
||||||
"protobufjs": "7.6.5",
|
"protobufjs": "7.6.3",
|
||||||
"sharp": "0.35.5"
|
"sharp": "0.35.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,71 +8,6 @@
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
var shown = false;
|
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) {
|
function paint(kind, err) {
|
||||||
if (shown) return;
|
if (shown) return;
|
||||||
shown = true;
|
shown = true;
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
<!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.83/SkateSpots-1.7.83.apk"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<span>Download SkateSpots-1.7.83.apk · 60 MB</span>
|
|
||||||
<span aria-hidden="true">↓</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<span>Requires Android 7.0 or newer.</span>
|
|
||||||
<span>SHA-256: <code>7c4f8e7a50428c97caba41b311df29a0e619f88a0ef00c686a021fbdc8f75ec0</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>
|
|
||||||
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "SkateSpots — Open-sourced Skate Community App",
|
"name": "SkateSpots - Decentralized Skate Map",
|
||||||
"short_name": "SkateSpots",
|
"short_name": "SkateSpots",
|
||||||
"id": "/",
|
"id": "/",
|
||||||
"description": "Map, crews, events, gear and more — an open-sourced skate community app.",
|
"description": "Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
@ -18,26 +18,20 @@
|
||||||
"related_applications": [],
|
"related_applications": [],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/brand-192.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "192x192",
|
"sizes": "180x180",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/brand-512.png",
|
"src": "/images/icon-maskable-1024.png",
|
||||||
"sizes": "512x512",
|
"sizes": "180x180",
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/brand-maskable-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/brand-1024.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "1024x1024",
|
"sizes": "180x180",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +60,7 @@
|
||||||
"url": "/map",
|
"url": "/map",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/brand-180.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +73,7 @@
|
||||||
"url": "/events",
|
"url": "/events",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/brand-180.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +86,7 @@
|
||||||
"url": "/coaches",
|
"url": "/coaches",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/brand-180.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +99,7 @@
|
||||||
"url": "/wallet",
|
"url": "/wallet",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/brand-180.png",
|
"src": "/images/icon-1024.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* navigations are network-first with an index fallback. No API, DM or model
|
* navigations are network-first with an index fallback. No API, DM or model
|
||||||
* request is ever stored; only same-origin static bytes.
|
* request is ever stored; only same-origin static bytes.
|
||||||
*/
|
*/
|
||||||
const SHELL_CACHE = 'skate-spots-shell-v3';
|
const SHELL_CACHE = 'skate-spots-shell-v2';
|
||||||
const SHELL_ASSETS = ['/', '/index.html'];
|
const SHELL_ASSETS = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
|
@ -39,12 +39,6 @@ self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/.netlify/')) 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') {
|
if (request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|
|
||||||
|
|
@ -1,546 +1,3 @@
|
||||||
🛹 OpenStreetMap tiles load again
|
|
||||||
- OSM was refusing the app because tile requests sent no Referer. The app now sends only the site origin (strict-origin-when-cross-origin), which satisfies OSM's tile policy without exposing page paths.
|
|
||||||
- A fresh osm-v3 tile cache means previously cached "Access blocked" tiles are never reused.
|
|
||||||
- OpenStreetMap remains the basemap; no CARTO or other provider was added.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛹 Orange wheel icon restored
|
|
||||||
- The app header, mobile header, splash screen and footer now use the orange SkateSpots wheel again.
|
|
||||||
- The same orange artwork is used for the PWA icons, Apple touch icon, app install metadata and Zapstore listing.
|
|
||||||
- Refreshed the social preview card with the open-sourced Skate Community App wording.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛹 OpenStreetMap tiles kept, cache fixed
|
|
||||||
- OpenStreetMap remains the basemap.
|
|
||||||
- Bumped the tile-cache namespace so previously cached OSM "Access blocked" tiles can never be reused.
|
|
||||||
- The map still depends on OSM's public tile service; if OSM blocks the current network/VPN range, the tiles cannot load until the network changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛹 Grantmaker inquiry, reworked
|
|
||||||
- The Bitcoin Grantmakers tab is now directly shareable at /supporters?tab=grants.
|
|
||||||
- The form is progressive: organization, contact and confirmation first; optional grant details are collapsed.
|
|
||||||
- Added website validation, clearer contact guidance, required-state messaging and a review dialog before the private DM opens.
|
|
||||||
- You can now copy the exact grant message as a fallback, with a clearer success/failure state.
|
|
||||||
- Added concrete examples of what a grant can fund, better contrast and larger touch targets.
|
|
||||||
- Shortened the mobile tab to Grants and softened the grantmaker copy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛹 Community and relay hygiene
|
|
||||||
- The community room invite now uses the canonical /community link and its generated QR.
|
|
||||||
- Removed the retired goskatespots.nostr1.com relay from active CVM, repository and NIP-65 relay lists. It remains only in the blocklist that prunes old client configs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛹 Marketplace UX overhaul
|
|
||||||
- Search, category, listing type, fulfilment, trust and sorting now live in the URL, so refresh, Back/Forward, shared links and saved searches keep their context.
|
|
||||||
- Mobile browsing gets a proper filter sheet and removable active-filter chips instead of a wall of category buttons.
|
|
||||||
- Price sorting now normalizes BTC, sats and fiat to the same value before comparing; listings without an available rate sort last.
|
|
||||||
- Marketplace cards are real links again: keyboard activation, open-in-new-tab, middle-click and the browser context menu all work.
|
|
||||||
- Listing pages get a mobile action sheet for buying, contacting, offers, escrow and reporting, plus a sticky price/action bar.
|
|
||||||
- Contact and share links are canonical and safe to share from the Android app.
|
|
||||||
- Create-listing forms now autosave a local draft, restore it after refresh or accidental navigation, and offer a discard action.
|
|
||||||
- Added a live listing preview before publishing, plus photo cover selection and left/right reordering.
|
|
||||||
- The marketplace now supports Show More instead of silently stopping at the first 200 listings.
|
|
||||||
- Unknown category links now fall back safely instead of producing an empty filter state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🔐 Nsec is no longer stored persistently
|
|
||||||
- Raw nsec logins now live only in sessionStorage. Reloading the tab keeps the
|
|
||||||
session; closing the browser/app requires re-entering the key.
|
|
||||||
- Existing raw nsec entries are migrated out of localStorage on the next write.
|
|
||||||
- The login warning now says this explicitly and points to Amber/bunker for
|
|
||||||
staying signed in. Added a regression test that asserts no `nsec1` value lands
|
|
||||||
in persistent localStorage.
|
|
||||||
- Direct links to `/spot`, `/crew`, `/event`, `/listing`, `/card` and similar
|
|
||||||
paths now rewrite into the HashRouter before React mounts, including cold
|
|
||||||
native App Links.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
👛 Built-in wallet first on mobile
|
|
||||||
- On the native app, the Wallet page and wallet manager now lead with the
|
|
||||||
built-in SkateSpots ecash wallet. Connecting an existing Lightning wallet is
|
|
||||||
the secondary path.
|
|
||||||
- The web build keeps the connected-wallet card first because Cashu minting is
|
|
||||||
native-only.
|
|
||||||
- Slightly larger bottom-nav labels for readability on phones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
👛 A simpler wallet setup
|
|
||||||
- Removed the four-wallet recommendation list from the primary wallet flow.
|
|
||||||
- Removed the “Browser Extension · Not Found” row; WebLN only appears when a
|
|
||||||
provider is actually detected.
|
|
||||||
- Removed the duplicate “Add Wallet” action when no wallet is connected. There
|
|
||||||
is now one clear “Connect Wallet” path.
|
|
||||||
- Added one neutral help line and a generic “how wallets work” link instead of
|
|
||||||
sending users through a brand directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
📱 Phone/PWA install polish
|
|
||||||
- Added proper 192px and 512px app icons plus a 512px maskable icon, and a real
|
|
||||||
180px Apple touch icon. The manifest no longer advertises 180px images as
|
|
||||||
1024px.
|
|
||||||
- Fixed the signup recovery-code print popup to use the dynamic viewport height,
|
|
||||||
so it no longer overflows behind mobile browser chrome.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🧵 Crew coordinator routing is now correct
|
|
||||||
- A crew’s own coordinator is authoritative. If its pointer is incomplete
|
|
||||||
(custom relay without a service pubkey), SkateSpots no longer silently falls
|
|
||||||
back to the SkateSpots coordinator.
|
|
||||||
- Join-request notifications now poll each crew’s coordinator independently, so
|
|
||||||
crews running their own coordinator get request notifications too. One
|
|
||||||
unreachable custom coordinator no longer stops notifications for other crews.
|
|
||||||
- Device-link failures now show an inline error with a Retry button instead of a
|
|
||||||
permanently spinning dialog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🏠 One command to run a crew coordinator
|
|
||||||
- Added `deploy/cordn/install.sh`: it generates the coordinator key, writes a
|
|
||||||
private `.env`, starts persistent SQLite storage, and prints the nprofile/add
|
|
||||||
link when the coordinator announces.
|
|
||||||
- Settings → Coordinator now shows that one-command installer instead of the
|
|
||||||
long Docker command.
|
|
||||||
- Documented the phone limit honestly: a phone can administer a coordinator or
|
|
||||||
use Cordn’s app, but an always-on crew coordinator belongs on a small server.
|
|
||||||
- No inbound port, domain or TLS terminator is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🔑 Simpler sign-in choices
|
|
||||||
- The login dialog now shows two primary methods instead of three:
|
|
||||||
**Browser extension** and a platform-aware signer card.
|
|
||||||
- On Android the signer card is **Amber (NIP-55)**. On desktop it is
|
|
||||||
**Pair a signer** via NIP-46 QR.
|
|
||||||
- Removed the duplicate NIP-46 card. Desktop QR pairing now contains the
|
|
||||||
“Paste a bunker URI instead” path, with a back link.
|
|
||||||
- Amber users on Android get a direct “Use another signer (QR)” escape hatch.
|
|
||||||
- Fixed the QR button markup and clarified that QR pairing is for NIP-46
|
|
||||||
signers such as nsec.app/Nostrum; Amber is the one-tap Android path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛡️ Verify before showing, and stop leaking referrers
|
|
||||||
- The shared skate-content query path now verifies every relay event's id and
|
|
||||||
signature before merge/display. Trust-sensitive reads no longer rely on the
|
|
||||||
relay implementation to have checked it.
|
|
||||||
- User images and avatars now use `referrerPolicy="no-referrer"`; the site
|
|
||||||
Referrer-Policy is `no-referrer` too, so third-party image hosts do not learn
|
|
||||||
which SkateSpots page you came from.
|
|
||||||
- These are defense-in-depth changes; no new user prompt or gate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🎯 Status control no longer crowds the header
|
|
||||||
- The desktop status control is now a compact emoji-only round chip with a
|
|
||||||
tooltip, instead of a text pill that could collide with the profile controls.
|
|
||||||
- Removed the duplicate online dot from the mobile avatar; the status indicator
|
|
||||||
is the single source of truth there.
|
|
||||||
- Existing status dialog, avatar shortcut and mobile menu behavior are unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛡️ Amber is quieter and safer
|
|
||||||
- Encrypted-message inboxes no longer auto-decrypt on arrival for Amber users.
|
|
||||||
Messages now show a deliberate “Unlock messages” card; opening Messages itself
|
|
||||||
does not fire a decrypt storm. The gate is Amber-only; silent signers keep the
|
|
||||||
automatic inbox.
|
|
||||||
- Amber redirect operations now expire after 10 minutes; stale callbacks are
|
|
||||||
rejected and cleared, so an old request cannot be replayed later.
|
|
||||||
- Redirect encryption operations no longer persist plaintext in localStorage,
|
|
||||||
sessionStorage or IndexedDB. Crypto results are no longer left in browser
|
|
||||||
storage.
|
|
||||||
- Added regression tests for stale Amber callback handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🎯 Quiet status control
|
|
||||||
- The desktop header no longer shows a large green outlined “Status” pill with a
|
|
||||||
permanent ping. It now shows a compact chip with your current status emoji and
|
|
||||||
label, or a neutral “Status” affordance when none is set.
|
|
||||||
- The control stays a single tap to open the status dialog; the existing avatar
|
|
||||||
status dot and mobile status flow are unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🧭 Coordinator setup that actually verifies itself
|
|
||||||
- Settings → Coordinator now runs a real ContextVM RPC probe, not just a
|
|
||||||
heartbeat presence check. It shows RPC latency when reachable and clearly
|
|
||||||
distinguishes “visible but RPC unavailable”.
|
|
||||||
- Crew edit now has an optional coordinator operator field (npub/hex), published
|
|
||||||
in the existing coordinator tag. The operator is a label, not an admin key.
|
|
||||||
- The RPC check uses a throwaway local key, so it never prompts Amber or touches
|
|
||||||
your identity.
|
|
||||||
- Added a live coordinator RPC test (skipped by default; `CORDN_LIVE=1`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🏠 A real crew-owned coordinator kit
|
|
||||||
- Added `deploy/cordn/`: Docker Compose with SQLite persistence, `.env` template,
|
|
||||||
backup/restore scripts, and a runbook for a crew operator.
|
|
||||||
- The coordinator needs only outbound access to ContextVM relays — no inbound
|
|
||||||
port, domain or TLS terminator is required for the coordinator itself.
|
|
||||||
- Settings → Coordinator and the crew edit form now link to the production kit;
|
|
||||||
the displayed Docker command is persistent instead of the old `--rm` demo.
|
|
||||||
- The app still does not embed a coordinator. One crew-trusted coordinator is
|
|
||||||
the model; no multi-coordinator/BFT claims.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🎯 Value-aware safety and fresher reputation
|
|
||||||
- High-value fixed-price listings (100,000 sats or 100 USD/EUR/GBP) now get the
|
|
||||||
same compact safety note as new sellers: meet in public, prefer escrow for
|
|
||||||
shipped deals, never pay outside the app, and remember escrow is not
|
|
||||||
arbitration.
|
|
||||||
- Trade reputation now applies a 180-day half-life to the trust/activity weight,
|
|
||||||
while keeping lifetime counts and the existing distinct-partner/ring warnings.
|
|
||||||
The profile card also shows how many mutually-confirmed trades happened in the
|
|
||||||
last 90 days.
|
|
||||||
- Added the unit-tested NUT-11 2-of-3 escrow policy primitive (seller+arbiter /
|
|
||||||
buyer+arbiter). It is not exposed in the UI until an arbiter policy, bond and
|
|
||||||
appeal process exist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🔎 Relay consistency and recent reputation
|
|
||||||
- Settings → Relays has an on-demand consistency check: it compares a bounded
|
|
||||||
profile snapshot from the community relay with your read relays and reports
|
|
||||||
newer/different signed profiles. It never merges data or treats relays as
|
|
||||||
voters.
|
|
||||||
- Trade reputation now shows how many mutually-confirmed trades happened in the
|
|
||||||
last 90 days, alongside the lifetime count and existing distinct-partner
|
|
||||||
warnings. Older reputation remains visible; this is a recency signal, not a
|
|
||||||
reset.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🛡️ A clearer warning before a new seller deal
|
|
||||||
- Listings from a seller with no confirmed trading partner, or an account under
|
|
||||||
30 days old, now show a compact safety note with the marketplace guidance:
|
|
||||||
meet in public, prefer escrow for shipped deals, and never pay outside the
|
|
||||||
app’s rails.
|
|
||||||
- The note sits beside the trade actions only — browsing stays frictionless.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🔒 Privacy, privacy, consistent merging
|
|
||||||
- Relay results are now merged deterministically with a newest-event rule and a
|
|
||||||
stable event-id tie-break instead of “first relay wins”. Local deletion
|
|
||||||
tombstones are applied during the same merge, so a deleted item cannot flash
|
|
||||||
back because a different relay answered first.
|
|
||||||
- Photo uploads are redrawn through a canvas before Blossom, stripping EXIF/GPS
|
|
||||||
metadata from decodable images. GIF and HEIC/HEIF still pass through as-is
|
|
||||||
because they need special handling.
|
|
||||||
- Android cloud backup is disabled for the app: local keys, settings and pending
|
|
||||||
escrow state stay on the device and must be exported explicitly.
|
|
||||||
- The OpenSats footer icon is now served locally, so opening the app does not
|
|
||||||
contact opensats.org just to render the footer.
|
|
||||||
- Added a technical threat-model document and linked it from the trust page:
|
|
||||||
signed facts, no relay consensus, no delivery proof, no arbitration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🧹 Small correctness pass
|
|
||||||
- Unknown routes now return home with a real in-app link, so the native app no
|
|
||||||
longer does a full page reload on a 404.
|
|
||||||
- Profile badge links are sanitized before rendering, blocking `javascript:` and
|
|
||||||
other unsafe URL schemes from user metadata.
|
|
||||||
- Marketplace payment readiness now only says “ready” when checkout has a
|
|
||||||
Lightning address it can actually pay; an LNURL-only profile is no longer
|
|
||||||
advertised as ready.
|
|
||||||
- Removed the duplicate North Korean option from the language picker while
|
|
||||||
keeping stored preferences valid.
|
|
||||||
- The beta screen’s “View the code” link now points at the self-hosted source
|
|
||||||
instead of a GitHub search.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🧩 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
|
🚐 Transport / Driver profiles in The Lab
|
||||||
- New Transport craft for skaters who move people, gear or cargo:
|
- New Transport craft for skaters who move people, gear or cargo:
|
||||||
cars, taxis, vans, motorcycles, helicopters, planes and boats.
|
cars, taxis, vans, motorcycles, helicopters, planes and boats.
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 23 KiB |
|
|
@ -55,8 +55,6 @@ interface Crew {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
coordinatorPubkey: string;
|
|
||||||
coordinatorRelays: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WatcherState {
|
interface WatcherState {
|
||||||
|
|
@ -132,18 +130,8 @@ function crewFromEvent(event: {
|
||||||
const name = event.tags.find(([name]) => name === 'title')?.[1];
|
const name = event.tags.find(([name]) => name === 'title')?.[1];
|
||||||
if (!d || !name) return null;
|
if (!d || !name) return null;
|
||||||
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
|
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
|
||||||
const coordinatorUrl = coordinatorTag?.[1];
|
const coordinatorPubkey = coordinatorTag?.[2];
|
||||||
const configuredPubkey = coordinatorTag?.[2];
|
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null;
|
||||||
const isDefaultRelay = !!coordinatorUrl && coordinatorUrl === SKATESPOTS_COORDINATOR.relays[0];
|
|
||||||
// A custom relay without a service pubkey is not addressable. Do not fall
|
|
||||||
// back to SkateSpots' coordinator on someone else's relay.
|
|
||||||
if (coordinatorUrl && !configuredPubkey && !isDefaultRelay) return null;
|
|
||||||
const coordinatorPubkey = configuredPubkey ?? SKATESPOTS_COORDINATOR.pubkey;
|
|
||||||
if (!/^[0-9a-f]{64}$/i.test(coordinatorPubkey)) return null;
|
|
||||||
const coordinatorRelays =
|
|
||||||
coordinatorUrl && /^wss?:\/\//i.test(coordinatorUrl)
|
|
||||||
? [coordinatorUrl]
|
|
||||||
: SKATESPOTS_COORDINATOR.relays;
|
|
||||||
const admins = event.tags
|
const admins = event.tags
|
||||||
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
|
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
|
||||||
.map(([, value]) => value.toLowerCase());
|
.map(([, value]) => value.toLowerCase());
|
||||||
|
|
@ -154,8 +142,6 @@ function crewFromEvent(event: {
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
identifier: d,
|
identifier: d,
|
||||||
recipients,
|
recipients,
|
||||||
coordinatorPubkey,
|
|
||||||
coordinatorRelays,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,44 +264,22 @@ async function main(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
const coordinator = new CordnCoordinatorClient({
|
||||||
const groupsByCoordinator = new Map<string, { pubkey: string; relays: string[]; crews: Crew[] }>();
|
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
|
||||||
for (const crew of crews) {
|
relays: SKATESPOTS_COORDINATOR.relays,
|
||||||
const key = `${crew.coordinatorPubkey}|${crew.coordinatorRelays.slice().sort().join(',')}`;
|
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''),
|
||||||
const group = groupsByCoordinator.get(key) ?? {
|
});
|
||||||
pubkey: crew.coordinatorPubkey,
|
|
||||||
relays: crew.coordinatorRelays,
|
|
||||||
crews: [],
|
|
||||||
};
|
|
||||||
group.crews.push(crew);
|
|
||||||
groupsByCoordinator.set(key, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requests: FetchManyPendingJoinRequestsOutput['requests'] = [];
|
let requests: FetchManyPendingJoinRequestsOutput;
|
||||||
for (const group of groupsByCoordinator.values()) {
|
try {
|
||||||
const coordinator = new CordnCoordinatorClient({
|
requests = await coordinator.fetchManyPendingJoinRequests({
|
||||||
serverPubkey: group.pubkey,
|
groups: crews.map((crew) => ({ gid: crew.gid })),
|
||||||
relays: group.relays,
|
|
||||||
privateKeyHex,
|
|
||||||
});
|
});
|
||||||
try {
|
} finally {
|
||||||
const result = await coordinator.fetchManyPendingJoinRequests({
|
await coordinator.disconnect().catch(() => undefined);
|
||||||
groups: group.crews.map((crew) => ({ gid: crew.gid })),
|
|
||||||
});
|
|
||||||
requests.push(...result.requests);
|
|
||||||
} catch (error) {
|
|
||||||
// A custom coordinator can be offline/private while other crews are
|
|
||||||
// healthy. Keep polling the rest instead of failing the whole watcher.
|
|
||||||
console.error(
|
|
||||||
`[cordn-watcher] coordinator ${shortNpub(group.pubkey)} unavailable:`,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await coordinator.disconnect().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requests.length === 0) {
|
if (requests.requests.length === 0) {
|
||||||
pool.close([COMMUNITY]);
|
pool.close([COMMUNITY]);
|
||||||
console.log('[cordn-watcher] no pending join requests');
|
console.log('[cordn-watcher] no pending join requests');
|
||||||
return;
|
return;
|
||||||
|
|
@ -325,7 +289,7 @@ async function main(): Promise<void> {
|
||||||
const state = loadState();
|
const state = loadState();
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of requests.requests) {
|
||||||
const crew = byGid.get(request.gid);
|
const crew = byGid.get(request.gid);
|
||||||
if (!crew) continue;
|
if (!crew) continue;
|
||||||
const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
|
const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
|
||||||
|
|
@ -378,7 +342,7 @@ async function main(): Promise<void> {
|
||||||
{
|
{
|
||||||
watcher: nip19.npubEncode(watcherPubkey),
|
watcher: nip19.npubEncode(watcherPubkey),
|
||||||
crews: crews.length,
|
crews: crews.length,
|
||||||
pending: requests.length,
|
pending: requests.requests.length,
|
||||||
newRequests: sent,
|
newRequests: sent,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -35,92 +35,18 @@ svc skatespots-relay.service
|
||||||
svc skatespots-ai.service
|
svc skatespots-ai.service
|
||||||
svc skatespots-push.service
|
svc skatespots-push.service
|
||||||
svc skatespots-cvm.service
|
svc skatespots-cvm.service
|
||||||
svc skatespots-quota.service
|
|
||||||
svc caddy.service
|
svc caddy.service
|
||||||
|
|
||||||
[ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \
|
[ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \
|
||||||
&& ISSUES+=("relay public TLS not answering 200")
|
&& ISSUES+=("relay public TLS not answering 200")
|
||||||
[ "$(code https://push.skate-spots.org/health)" != "200" ] \
|
[ "$(code https://push.skate-spots.org/health)" != "200" ] \
|
||||||
&& ISSUES+=("push /health not 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=$(df --output=pcent / | tail -1 | tr -d ' %')
|
||||||
[ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used")
|
[ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used")
|
||||||
avail=$(free -m | awk '/Mem:/{print $7}')
|
avail=$(free -m | awk '/Mem:/{print $7}')
|
||||||
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available")
|
[ "${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=""
|
sig=""
|
||||||
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
|
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
|
||||||
prev=""
|
prev=""
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Disable Android Auto Backup for SkateSpots.
|
|
||||||
*
|
|
||||||
* The app stores identity material, local settings, pending escrow tokens and
|
|
||||||
* other private state in its WebView storage. Android Auto Backup can copy that
|
|
||||||
* data to a cloud account without the user ever asking for a backup. Keep it
|
|
||||||
* device-local: SkateSpots has its own explicit export/backup flows.
|
|
||||||
*
|
|
||||||
* Idempotent; exits quietly when android/ has not been generated yet.
|
|
||||||
*/
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const manifestPath = join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
||||||
|
|
||||||
if (!existsSync(manifestPath)) {
|
|
||||||
console.log('[inject-backup-policy] android/ not generated yet — skipping.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest = readFileSync(manifestPath, 'utf8');
|
|
||||||
const before = manifest;
|
|
||||||
|
|
||||||
if (/android:allowBackup="[^"]*"/.test(manifest)) {
|
|
||||||
manifest = manifest.replace(/android:allowBackup="[^"]*"/, 'android:allowBackup="false"');
|
|
||||||
} else {
|
|
||||||
manifest = manifest.replace(
|
|
||||||
/<application\b/,
|
|
||||||
'<application android:allowBackup="false"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest === before) {
|
|
||||||
console.log('[inject-backup-policy] allowBackup already disabled.');
|
|
||||||
} else {
|
|
||||||
writeFileSync(manifestPath, manifest);
|
|
||||||
console.log('[inject-backup-policy] ✓ Disabled Android cloud backup (allowBackup=false).');
|
|
||||||
}
|
|
||||||
|
|
@ -15,12 +15,7 @@
|
||||||
* 1. Removes any previously managed signing block.
|
* 1. Removes any previously managed signing block.
|
||||||
* 2. Removes a legacy hardcoded signingConfigs block (inline passwords).
|
* 2. Removes a legacy hardcoded signingConfigs block (inline passwords).
|
||||||
* 3. Writes a managed signingConfigs block into android/app/build.gradle that
|
* 3. Writes a managed signingConfigs block into android/app/build.gradle that
|
||||||
* reads secrets from a keystore.properties file outside the repo.
|
* reads secrets from the gitignored keystore.properties at the repo root.
|
||||||
*
|
|
||||||
* The signing material is deliberately outside the Vite project root:
|
|
||||||
* ~/.config/skatespots/android/keystore.properties
|
|
||||||
* ~/.config/skatespots/android/skatespots-release.jks
|
|
||||||
* Override the properties path with SKATESPOTS_KEYSTORE_PROPERTIES.
|
|
||||||
*
|
*
|
||||||
* It exits quietly (0) when android/ isn't generated yet, or when
|
* It exits quietly (0) when android/ isn't generated yet, or when
|
||||||
* keystore.properties is absent — so debug-only builds without a keystore still
|
* keystore.properties is absent — so debug-only builds without a keystore still
|
||||||
|
|
@ -28,16 +23,11 @@
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const buildGradle = join(root, 'android', 'app', 'build.gradle');
|
const buildGradle = join(root, 'android', 'app', 'build.gradle');
|
||||||
const keystoreProps = resolve(
|
const keystoreProps = join(root, 'keystore.properties');
|
||||||
process.env.SKATESPOTS_KEYSTORE_PROPERTIES ??
|
|
||||||
join(homedir(), '.config', 'skatespots', 'android', 'keystore.properties'),
|
|
||||||
);
|
|
||||||
const gradleString = (value) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
||||||
|
|
||||||
const BEGIN = '// >>> managed by scripts/inject-signing-config.mjs';
|
const BEGIN = '// >>> managed by scripts/inject-signing-config.mjs';
|
||||||
const END = '// <<< managed by scripts/inject-signing-config.mjs <<<';
|
const END = '// <<< managed by scripts/inject-signing-config.mjs <<<';
|
||||||
|
|
@ -53,11 +43,10 @@ if (!existsSync(keystoreProps)) {
|
||||||
|
|
||||||
const managedLines = [
|
const managedLines = [
|
||||||
`${BEGIN} >>>`,
|
`${BEGIN} >>>`,
|
||||||
'// Release signing is read from keystore.properties outside the repo.',
|
'// Release signing is read from keystore.properties at the repo root',
|
||||||
'// Edit that file, not this block.',
|
'// (gitignored). Edit that file, not this block.',
|
||||||
'def _keystoreProps = new Properties()',
|
'def _keystoreProps = new Properties()',
|
||||||
"def _keystorePropsPath = System.getenv('SKATESPOTS_KEYSTORE_PROPERTIES')",
|
"def _keystorePropsFile = rootProject.file('../keystore.properties')",
|
||||||
`def _keystorePropsFile = new File(_keystorePropsPath ?: '${gradleString(keystoreProps)}')`,
|
|
||||||
'if (_keystorePropsFile.exists()) {',
|
'if (_keystorePropsFile.exists()) {',
|
||||||
' _keystorePropsFile.withInputStream { _keystoreProps.load(it) }',
|
' _keystorePropsFile.withInputStream { _keystoreProps.load(it) }',
|
||||||
'}',
|
'}',
|
||||||
|
|
|
||||||
|
|
@ -218,23 +218,12 @@ p ""
|
||||||
p "${B}Community bot${N}"
|
p "${B}Community bot${N}"
|
||||||
COMMUNITY_HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
|
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
|
if command -v ssh >/dev/null 2>&1 && grep -qsE "^Host[[:space:]]+${COMMUNITY_HOST}([[:space:]]|$)" "$HOME/.ssh/config"; then
|
||||||
# A failed SSH connection is NOT evidence that the remote bot is down. The
|
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
|
||||||
# previous version turned an empty SSH result into "unreachable" and emitted
|
if [ "$community_state" = "active" ]; then
|
||||||
# an ops notification on every transient network/DNS hiccup. Retry once, then
|
|
||||||
# report an honest unknown state without alerting.
|
|
||||||
community_state=""
|
|
||||||
for attempt in 1 2; do
|
|
||||||
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
|
|
||||||
[ -n "$community_state" ] && break
|
|
||||||
[ "$attempt" = "1" ] && sleep 1
|
|
||||||
done
|
|
||||||
if [ -z "$community_state" ]; then
|
|
||||||
p " ${Y}⚠${N} skatespots-community: health check unreachable (SSH failed; state unknown)"
|
|
||||||
elif [ "$community_state" = "active" ]; then
|
|
||||||
p " ${G}✓${N} skatespots-community active"
|
p " ${G}✓${N} skatespots-community active"
|
||||||
else
|
else
|
||||||
p " ${R}✗${N} skatespots-community: ${community_state}"
|
p " ${R}✗${N} skatespots-community: ${community_state:-unreachable}"
|
||||||
problems+=("community Cordn bot is ${community_state}")
|
problems+=("community Cordn bot is ${community_state:-unreachable}")
|
||||||
signature+="community-bot;"
|
signature+="community-bot;"
|
||||||
fi
|
fi
|
||||||
community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)"
|
community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)"
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
#!/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,14 +126,6 @@ for _ in 1 2 3 4 5 6; do
|
||||||
done
|
done
|
||||||
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
|
[ "$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…"
|
log "committing the release…"
|
||||||
git add package.json package-lock.json release-notes.txt
|
git add package.json package-lock.json release-notes.txt
|
||||||
git commit -m "release: $VERSION" >/dev/null
|
git commit -m "release: $VERSION" >/dev/null
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -16,7 +16,7 @@ import NostrProvider from '@/components/NostrProvider';
|
||||||
import { NostrSync } from '@/components/NostrSync';
|
import { NostrSync } from '@/components/NostrSync';
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { NostrLoginProvider } from '@/lib/nostrLogin';
|
import { NostrLoginProvider } from '@nostrify/react/login';
|
||||||
import { AppProvider } from '@/components/AppProvider';
|
import { AppProvider } from '@/components/AppProvider';
|
||||||
import { NWCProvider } from '@/contexts/NWCContext';
|
import { NWCProvider } from '@/contexts/NWCContext';
|
||||||
import { LanguageProvider } from '@/contexts/LanguageContext';
|
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import { BECOME_ROLES } from "./lib/becomeRoles";
|
||||||
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
||||||
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { RoutePreloader } from "./components/RoutePreloader";
|
|
||||||
|
|
||||||
// HomePage resolves to a lazy map on web (smaller first entry) and to the
|
// The map is the homepage, but lazy-loading it keeps its chunk (plus the
|
||||||
// eager MapPage via a Capacitor-only Vite alias on Android.
|
// IndexedDB offline-cache layer) out of the initial entry bundle.
|
||||||
import HomePage from "@/pages/HomePage";
|
const MapPage = lazyWithRetry(() => import("./pages/MapPage"));
|
||||||
|
|
||||||
// Lazy load everything else — each page becomes its own JS chunk
|
// Lazy load everything else — each page becomes its own JS chunk
|
||||||
const Index = lazyWithRetry(() => import("./pages/Index"));
|
const Index = lazyWithRetry(() => import("./pages/Index"));
|
||||||
|
|
@ -23,7 +22,6 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
||||||
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
||||||
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
|
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
|
||||||
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
||||||
const OpsPage = lazyWithRetry(() => import("./pages/OpsPage"));
|
|
||||||
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
||||||
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
||||||
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
|
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
|
||||||
|
|
@ -79,6 +77,7 @@ const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
||||||
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
|
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
|
||||||
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
|
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
|
||||||
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
||||||
|
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
|
||||||
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
||||||
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
|
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
|
||||||
const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
|
const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
|
||||||
|
|
@ -100,7 +99,6 @@ export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<DeepLinkHandler />
|
<DeepLinkHandler />
|
||||||
<RoutePreloader />
|
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
{/* Per-route boundary: a crash in one page shows a recoverable message
|
{/* Per-route boundary: a crash in one page shows a recoverable message
|
||||||
instead of blanking the whole app. Resets automatically on navigate
|
instead of blanking the whole app. Resets automatically on navigate
|
||||||
|
|
@ -113,9 +111,9 @@ export function AppRouter() {
|
||||||
navigation. Pages no longer wrap themselves in <Layout>. */}
|
navigation. Pages no longer wrap themselves in <Layout>. */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Map is the homepage */}
|
{/* Map is the homepage */}
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<MapPage />} />
|
||||||
{/* /map redirects to / */}
|
{/* /map redirects to / */}
|
||||||
<Route path="/map" element={<HomePage />} />
|
<Route path="/map" element={<MapPage />} />
|
||||||
{/* Legacy home page still accessible */}
|
{/* Legacy home page still accessible */}
|
||||||
<Route path="/home" element={<Index />} />
|
<Route path="/home" element={<Index />} />
|
||||||
<Route path="/events" element={<EventsPage />} />
|
<Route path="/events" element={<EventsPage />} />
|
||||||
|
|
@ -159,7 +157,6 @@ export function AppRouter() {
|
||||||
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
||||||
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
||||||
<Route path="/review" element={<AdminReviewPage />} />
|
<Route path="/review" element={<AdminReviewPage />} />
|
||||||
<Route path="/ops" element={<OpsPage />} />
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||||
<Route path="/create-shop" element={<CreateShopPage />} />
|
<Route path="/create-shop" element={<CreateShopPage />} />
|
||||||
|
|
@ -195,11 +192,9 @@ export function AppRouter() {
|
||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
<Route path="/supporters" element={<SupportersPage />} />
|
<Route path="/supporters" element={<SupportersPage />} />
|
||||||
{/* Friendly alias: people naturally type /support. */}
|
|
||||||
<Route path="/support" element={<Navigate to="/supporters" replace />} />
|
|
||||||
<Route path="/donate" element={<DonatePage />} />
|
<Route path="/donate" element={<DonatePage />} />
|
||||||
{/* Marketplace */}
|
{/* Marketplace */}
|
||||||
<Route path="/marketplace-listings" element={<Navigate to="/marketplace" replace />} />
|
<Route path="/marketplace-listings" element={<MarketplacePage />} />
|
||||||
{/* Newsletter */}
|
{/* Newsletter */}
|
||||||
<Route path="/newsletter" element={<NewsletterPage />} />
|
<Route path="/newsletter" element={<NewsletterPage />} />
|
||||||
{/* Trick Glossary */}
|
{/* Trick Glossary */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ShieldAlert, ArrowRight, Activity } from 'lucide-react';
|
import { ShieldAlert, ArrowRight } from 'lucide-react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
|
|
@ -24,37 +24,22 @@ export function AdminReviewCard() {
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<Link
|
||||||
<Link
|
to="/review"
|
||||||
to="/review"
|
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
|
||||||
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
|
>
|
||||||
>
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
|
||||||
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
|
||||||
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
{flaggedCount > 0
|
||||||
{flaggedCount > 0
|
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
|
||||||
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
|
: t('settingsUi.adminReview.clean')}
|
||||||
: t('settingsUi.adminReview.clean')}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
<Link
|
</div>
|
||||||
to="/ops"
|
</Link>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { KeyRound } from 'lucide-react';
|
import { KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ import { useTranslation } from '@/contexts/language';
|
||||||
const DISCLAIMER_VERSION = 1;
|
const DISCLAIMER_VERSION = 1;
|
||||||
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
|
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
|
||||||
|
|
||||||
const REPO_URL = 'https://git.skate-spots.org/skatespots/SkateSpots';
|
const REPO_URL =
|
||||||
|
'https://github.com/search?q=SkateSpots';
|
||||||
|
|
||||||
export function BetaDisclaimerDialog() {
|
export function BetaDisclaimerDialog() {
|
||||||
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,13 @@ import {
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||||
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
|
||||||
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||||
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { toast } from '@/hooks/useToast';
|
import { toast } from '@/hooks/useToast';
|
||||||
import {
|
import {
|
||||||
CORDN_APP_URL,
|
CORDN_APP_URL,
|
||||||
CORDN_INSTALL_COMMAND,
|
CORDN_DOCKER_COMMAND,
|
||||||
CORDN_DEPLOY_KIT_URL,
|
|
||||||
CORDN_RELAY_URL,
|
CORDN_RELAY_URL,
|
||||||
coordinatorAddUrl,
|
coordinatorAddUrl,
|
||||||
parseCoordinatorInput,
|
parseCoordinatorInput,
|
||||||
|
|
@ -29,7 +27,6 @@ import {
|
||||||
SKATESPOTS_COORDINATOR,
|
SKATESPOTS_COORDINATOR,
|
||||||
} from '@/lib/coordinator';
|
} from '@/lib/coordinator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { urlDomain } from '@/lib/sanitizeUrl';
|
|
||||||
|
|
||||||
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||||
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
||||||
|
|
@ -62,28 +59,6 @@ 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.
|
* Settings → Coordinator.
|
||||||
*
|
*
|
||||||
|
|
@ -95,7 +70,7 @@ function RelayHealthPill({ url }: { url: string }) {
|
||||||
export function CoordinatorSettingsCard() {
|
export function CoordinatorSettingsCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
|
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
|
||||||
const { status, lastSeen, eventCount, name, error, rpcStatus, rpcLatencyMs, check } = useCoordinatorHealth(coordinator);
|
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator);
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
|
@ -144,11 +119,6 @@ export function CoordinatorSettingsCard() {
|
||||||
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
||||||
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -176,17 +146,6 @@ export function CoordinatorSettingsCard() {
|
||||||
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{rpcStatus === 'ok' && (
|
|
||||||
<p className="text-[11px] font-medium text-green-400">
|
|
||||||
{t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rpcStatus === 'error' && (
|
|
||||||
<p className="text-[11px] text-amber-400">
|
|
||||||
{t('socialUi.coordinator.rpcError')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
||||||
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
|
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
|
||||||
|
|
@ -315,25 +274,17 @@ export function CoordinatorSettingsCard() {
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
|
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
|
||||||
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
|
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
|
||||||
{CORDN_INSTALL_COMMAND}
|
{CORDN_DOCKER_COMMAND}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyText(CORDN_INSTALL_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
onClick={() => void copyText(CORDN_DOCKER_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
||||||
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
||||||
aria-label={t('socialUi.coordinator.copyCommand')}
|
aria-label={t('socialUi.coordinator.copyCommand')}
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<a href={CORDN_DEPLOY_KIT_URL} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
{t('common.learnMore')}
|
|
||||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
|
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
|
||||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
{t('socialUi.coordinator.androidSleep')}
|
{t('socialUi.coordinator.androidSleep')}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
|
||||||
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
||||||
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
||||||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
import { isValidNoffer, type EntityType } from '@/lib/types';
|
||||||
import { normalizeClinkCode } from '@/lib/clink';
|
|
||||||
import type { LabOption } from '@/lib/labExtras';
|
import type { LabOption } from '@/lib/labExtras';
|
||||||
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
||||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
|
|
@ -328,7 +327,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
||||||
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
||||||
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
|
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
|
||||||
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
|
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
// Craft-specific extras
|
// Craft-specific extras
|
||||||
for (const f of extraFields) {
|
for (const f of extraFields) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,12 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||||
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
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 { 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 { 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 { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||||
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
||||||
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, parseOperatorPubkey, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator';
|
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
|
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
|
||||||
|
|
@ -336,7 +335,6 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
crewCoordinatorInputValue(existing?.coordinator) ||
|
crewCoordinatorInputValue(existing?.coordinator) ||
|
||||||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
|
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
|
||||||
);
|
);
|
||||||
const [coordinatorOperator, setCoordinatorOperator] = useState(existing?.coordinator?.operator ?? '');
|
|
||||||
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
||||||
const { coordinator: myCoordinator } = useCoordinator();
|
const { coordinator: myCoordinator } = useCoordinator();
|
||||||
// Live parse so the founder gets feedback (and the publish path reuses it).
|
// Live parse so the founder gets feedback (and the publish path reuses it).
|
||||||
|
|
@ -495,11 +493,6 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
|
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
|
|
||||||
if (coordinatorOperator.trim() && !operatorPubkey) {
|
|
||||||
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||||
|
|
@ -578,7 +571,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
if (s.trim()) tags.push(['social', s.trim()]);
|
if (s.trim()) tags.push(['social', s.trim()]);
|
||||||
}
|
}
|
||||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
// Members: admins get an `admin` role marker on their `p` tag.
|
// Members: admins get an `admin` role marker on their `p` tag.
|
||||||
for (const pk of members) {
|
for (const pk of members) {
|
||||||
|
|
@ -620,10 +613,6 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
if (parsedCoordinator) {
|
if (parsedCoordinator) {
|
||||||
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
||||||
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
||||||
if (operatorPubkey) {
|
|
||||||
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
|
|
||||||
coordinatorTag.push(operatorPubkey);
|
|
||||||
}
|
|
||||||
tags.push(coordinatorTag);
|
tags.push(coordinatorTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1194,15 +1183,6 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
>
|
>
|
||||||
{t('socialUi.coordinator.cordnCoordinator')}
|
{t('socialUi.coordinator.cordnCoordinator')}
|
||||||
</a>
|
</a>
|
||||||
{' · '}
|
|
||||||
<a
|
|
||||||
href={CORDN_DEPLOY_KIT_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
|
|
||||||
>
|
|
||||||
{t('common.learnMore')}
|
|
||||||
</a>
|
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -1252,21 +1232,6 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="crew-coordinator-operator" className="text-xs text-gray-400">
|
|
||||||
{t('socialUi.coordinator.operatorLabel')}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="crew-coordinator-operator"
|
|
||||||
placeholder={t('socialUi.coordinator.operatorPlaceholder')}
|
|
||||||
value={coordinatorOperator}
|
|
||||||
onChange={(e) => setCoordinatorOperator(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-gray-600">{t('socialUi.coordinator.operatorHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
DEFAULT_TAB_IDS,
|
DEFAULT_TAB_IDS,
|
||||||
type NavTab,
|
type NavTab,
|
||||||
} from '@/hooks/useNavPreferences';
|
} from '@/hooks/useNavPreferences';
|
||||||
import { useTranslation } from '@/contexts/language';
|
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MapPin, MoreHorizontal, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface CustomizeNavSheetProps {
|
interface CustomizeNavSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -20,14 +20,14 @@ interface CustomizeNavSheetProps {
|
||||||
/**
|
/**
|
||||||
* Bottom-sheet editor for the mobile nav bar.
|
* Bottom-sheet editor for the mobile nav bar.
|
||||||
*
|
*
|
||||||
* Layout model: Map (fixed left) · 3 user-picked · More (fixed right).
|
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right).
|
||||||
* The user toggles up to 3 destinations from the catalog and can reorder them.
|
* 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.
|
* Draft state is local until "Done", so changes feel deliberate and cancelable.
|
||||||
*/
|
*/
|
||||||
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
|
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
|
||||||
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
|
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Local draft so we can cancel without persisting.
|
// Local draft so we can cancel without persisting.
|
||||||
const [draft, setDraft] = useState<string[]>(middleIds);
|
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" />;
|
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
|
||||||
})}
|
})}
|
||||||
{/* Fixed More */}
|
{/* Fixed Profile */}
|
||||||
<PreviewSlot icon={MoreHorizontal} label={t('nav.more')} locked accent="#ff0040" />
|
<PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1002,10 +1002,10 @@ export function DeckCostCalculator() {
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Multi-sheet XLSX workbook (lazy — our tiny local OOXML writer is only
|
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand).
|
||||||
// loaded when the user asks for the spreadsheet).
|
|
||||||
const exportXlsx = async () => {
|
const exportXlsx = async () => {
|
||||||
const { downloadXlsx } = await import('@/lib/xlsx');
|
const XLSX = await import('xlsx');
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
const num = (v: number) => Number(v.toFixed(4));
|
const num = (v: number) => Number(v.toFixed(4));
|
||||||
const summary: (string | number)[][] = [
|
const summary: (string | number)[][] = [
|
||||||
[t('legacyMiscUi.exportTitle')],
|
[t('legacyMiscUi.exportTitle')],
|
||||||
|
|
@ -1024,29 +1024,24 @@ export function DeckCostCalculator() {
|
||||||
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
|
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
|
||||||
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
|
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
|
||||||
];
|
];
|
||||||
const equipment: (string | number)[][] = [
|
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([
|
||||||
[t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
|
[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)]),
|
...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'));
|
||||||
const materials: (string | number)[][] = [
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
|
||||||
[t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
|
[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)]),
|
...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'));
|
||||||
const processes: (string | number)[][] = [
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
|
||||||
[t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
|
[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)]),
|
...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'));
|
||||||
const packaging: (string | number)[][] = [
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
|
||||||
[t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
|
[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)]),
|
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
|
||||||
];
|
]), t('legacyMiscUi.exportSectionPackaging'));
|
||||||
downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [
|
XLSX.writeFile(wb, `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 () => {
|
const copySummary = async () => {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
||||||
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
||||||
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
||||||
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
||||||
import { normalizeClinkCode } from '@/lib/clink';
|
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
export const EditProfileForm: React.FC = () => {
|
export const EditProfileForm: React.FC = () => {
|
||||||
|
|
@ -53,7 +52,7 @@ export const EditProfileForm: React.FC = () => {
|
||||||
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
|
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
|
||||||
// form fields, so we manage it here and merge it on save.
|
// form fields, so we manage it here and merge it on save.
|
||||||
const [lud16, setLud16] = useState('');
|
const [lud16, setLud16] = useState('');
|
||||||
const lud16Trimmed = lud16.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
const lud16Trimmed = lud16.trim();
|
||||||
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
|
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
|
||||||
|
|
||||||
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
|
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
|
||||||
|
|
@ -225,7 +224,7 @@ export const EditProfileForm: React.FC = () => {
|
||||||
|
|
||||||
// Merge the custom CLINK offer code (or remove it when cleared).
|
// Merge the custom CLINK offer code (or remove it when cleared).
|
||||||
if (nofferValid) {
|
if (nofferValid) {
|
||||||
data.noffer = normalizeClinkCode(nofferTrimmed);
|
data.noffer = nofferTrimmed.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
delete data.noffer;
|
delete data.noffer;
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +493,6 @@ export const EditProfileForm: React.FC = () => {
|
||||||
value={lud16}
|
value={lud16}
|
||||||
onChange={setLud16}
|
onChange={setLud16}
|
||||||
placeholder="you@yourdomain.com"
|
placeholder="you@yourdomain.com"
|
||||||
inputMode="email"
|
|
||||||
status={
|
status={
|
||||||
lud16Invalid
|
lud16Invalid
|
||||||
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
||||||
|
|
@ -715,8 +713,7 @@ const FieldRow: React.FC<{
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
status: { kind: 'hint' | 'ok' | 'error'; text: string };
|
status: { kind: 'hint' | 'ok' | 'error'; text: string };
|
||||||
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => (
|
||||||
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status, inputMode }) => (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
|
<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>
|
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
|
||||||
|
|
@ -729,7 +726,6 @@ const FieldRow: React.FC<{
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
inputMode={inputMode}
|
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
{status.kind === 'error' ? (
|
{status.kind === 'error' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { LazyZapDialog } from '@/components/LazyZapDialog';
|
import { ZapDialog } from '@/components/ZapDialog';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -64,7 +64,7 @@ export function FundingGoalCard({
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<LazyZapDialog target={event}>
|
<ZapDialog target={event}>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Zap className="h-4 w-4" />
|
||||||
{reached ? 'Keep supporting' : 'Fund this event'}
|
{reached ? 'Keep supporting' : 'Fund this event'}
|
||||||
</button>
|
</button>
|
||||||
</LazyZapDialog>
|
</ZapDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -53,13 +52,6 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
const [showMarkSold, setShowMarkSold] = useState(false);
|
const [showMarkSold, setShowMarkSold] = useState(false);
|
||||||
|
|
||||||
const npub = nip19.npubEncode(item.pubkey);
|
const npub = nip19.npubEncode(item.pubkey);
|
||||||
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
|
|
||||||
// Keep shared links canonical in the native WebView too (capacitor://localhost
|
|
||||||
// is not a shareable origin).
|
|
||||||
const listingUrl = `https://skate-spots.org${listingPath}`;
|
|
||||||
const contactSeller = () => navigate(
|
|
||||||
`/messages/${npub}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
|
|
||||||
);
|
|
||||||
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
||||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||||
|
|
||||||
|
|
@ -135,28 +127,21 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`group relative h-full border-2 bg-black/90 transition-all hover:scale-[1.02] ${
|
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${
|
||||||
isWTB
|
isWTB
|
||||||
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
|
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
|
||||||
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
|
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
|
||||||
>
|
>
|
||||||
{/* Stretched link: real link semantics, context menu, middle-click and
|
<CardContent className="p-0">
|
||||||
keyboard activation. Interactive controls above re-enable pointer events. */}
|
|
||||||
<Link
|
|
||||||
to={listingPath}
|
|
||||||
aria-label={t('marketUi.card.viewTitle', { title: item.title })}
|
|
||||||
className="absolute inset-0 z-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
|
|
||||||
/>
|
|
||||||
<CardContent className="pointer-events-none relative z-10 p-0">
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className="relative aspect-square overflow-hidden rounded-t-lg">
|
<div className="relative aspect-square overflow-hidden rounded-t-lg">
|
||||||
{primaryImage ? (
|
{primaryImage ? (
|
||||||
<ImageWithFallback
|
<img
|
||||||
src={primaryImage}
|
src={primaryImage}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
fallback={<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
@ -213,7 +198,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
{/* More options menu — owners manage their listing (mark sold /
|
{/* More options menu — owners manage their listing (mark sold /
|
||||||
relist / delete), non-owners can report, admins can take down. */}
|
relist / delete), non-owners can report, admins can take down. */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="pointer-events-auto absolute bottom-2 right-2 z-20">
|
<div className="absolute bottom-2 right-2 z-20">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -373,8 +358,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(listingPath)}
|
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
|
||||||
className={`pointer-events-auto cursor-pointer text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
|
|
||||||
>
|
>
|
||||||
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
|
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
|
||||||
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
|
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
|
||||||
|
|
@ -394,10 +378,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div className="flex items-center gap-1">
|
||||||
className="pointer-events-auto flex cursor-pointer items-center gap-1"
|
|
||||||
onClick={() => navigate(listingPath)}
|
|
||||||
>
|
|
||||||
{sellerRating.rating > 0 ? (
|
{sellerRating.rating > 0 ? (
|
||||||
<span className="text-sm">{sellerRating.display}</span>
|
<span className="text-sm">{sellerRating.display}</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -422,14 +403,14 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`pointer-events-auto mt-3 w-full gap-2 ${
|
className={`w-full mt-3 gap-2 ${
|
||||||
isWTB
|
isWTB
|
||||||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||||||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
contactSeller();
|
navigate(`/messages/${npub}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4" />
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ export function ImageWithFallback({
|
||||||
onError,
|
onError,
|
||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
decoding = 'async',
|
decoding = 'async',
|
||||||
referrerPolicy = 'no-referrer',
|
|
||||||
...props
|
...props
|
||||||
}: ImageWithFallbackProps) {
|
}: ImageWithFallbackProps) {
|
||||||
const candidates = useMemo(() => imageCandidates(src), [src]);
|
const candidates = useMemo(() => imageCandidates(src), [src]);
|
||||||
|
|
@ -68,7 +67,6 @@ export function ImageWithFallback({
|
||||||
className={className}
|
className={className}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decoding={decoding}
|
decoding={decoding}
|
||||||
referrerPolicy={referrerPolicy}
|
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (index + 1 < candidates.length) {
|
if (index + 1 < candidates.length) {
|
||||||
setIndex(index + 1);
|
setIndex(index + 1);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import { useTranslation } from '@/contexts/language';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const VISIBLE_LANGUAGE_OPTIONS = LANGUAGE_OPTIONS.filter((language) => !language.hidden);
|
|
||||||
|
|
||||||
interface LanguageToggleProps {
|
interface LanguageToggleProps {
|
||||||
/** Show only the flag icon (compact mode) */
|
/** Show only the flag icon (compact mode) */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
|
@ -67,13 +65,13 @@ export function LanguageToggle({ compact = false, inline = false, className, var
|
||||||
|
|
||||||
const safeQuery = query.trim().toLowerCase();
|
const safeQuery = query.trim().toLowerCase();
|
||||||
const filteredLanguages = safeQuery
|
const filteredLanguages = safeQuery
|
||||||
? VISIBLE_LANGUAGE_OPTIONS.filter(
|
? LANGUAGE_OPTIONS.filter(
|
||||||
(l) =>
|
(l) =>
|
||||||
l.name.toLowerCase().includes(safeQuery) ||
|
l.name.toLowerCase().includes(safeQuery) ||
|
||||||
l.nativeName.toLowerCase().includes(safeQuery) ||
|
l.nativeName.toLowerCase().includes(safeQuery) ||
|
||||||
l.code.toLowerCase().includes(safeQuery)
|
l.code.toLowerCase().includes(safeQuery)
|
||||||
)
|
)
|
||||||
: VISIBLE_LANGUAGE_OPTIONS;
|
: LANGUAGE_OPTIONS;
|
||||||
|
|
||||||
const currentLanguage =
|
const currentLanguage =
|
||||||
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
|
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
|
||||||
|
|
@ -198,7 +196,7 @@ export function LanguageSelector() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
|
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{VISIBLE_LANGUAGE_OPTIONS.map((lang) => (
|
{LANGUAGE_OPTIONS.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => setLanguage(lang.code)}
|
onClick={() => setLanguage(lang.code)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isNativePlatform } from '@/lib/platform';
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
User,
|
User,
|
||||||
Plus,
|
Plus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Database,
|
||||||
Package,
|
Package,
|
||||||
Heart,
|
Heart,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
|
@ -51,14 +52,10 @@ import {
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
|
||||||
ArrowLeft,
|
|
||||||
Search,
|
Search,
|
||||||
SmilePlus,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
import { SKATING_STATUSES, useUserStatus } from '@/hooks/useUserStatus';
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -114,30 +111,6 @@ 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 {
|
interface LayoutProps {
|
||||||
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
||||||
* there are no children. */
|
* there are no children. */
|
||||||
|
|
@ -186,7 +159,6 @@ function NavLink({
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
|
|
@ -223,12 +195,8 @@ function MobileHeaderUserMenu({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<div className="flex items-center gap-0.5">
|
<DropdownMenuTrigger asChild>
|
||||||
<Link
|
<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">
|
||||||
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">
|
<Avatar className="h-7 w-7 border-2 border-green-500/50">
|
||||||
<AvatarImage src={avatar} alt={displayName} />
|
<AvatarImage src={avatar} alt={displayName} />
|
||||||
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
||||||
|
|
@ -237,17 +205,14 @@ function MobileHeaderUserMenu({
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||||||
</Link>
|
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
|
||||||
<DropdownMenuTrigger asChild>
|
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||||||
<button
|
{t('chromeUi.inBadge')}
|
||||||
type="button"
|
</span>
|
||||||
aria-label={t('chromeUi.menu')}
|
{/* Green online indicator dot */}
|
||||||
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"
|
<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" />
|
||||||
>
|
</button>
|
||||||
<ChevronDown className="h-4 w-4" />
|
</DropdownMenuTrigger>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
|
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
|
||||||
{/* User info header */}
|
{/* User info header */}
|
||||||
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||||
|
|
@ -283,6 +248,12 @@ function MobileHeaderUserMenu({
|
||||||
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
||||||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
|
|
@ -295,6 +266,36 @@ function MobileHeaderUserMenu({
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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 ── */}
|
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
@ -341,17 +342,21 @@ function MobileHeaderUserMenu({
|
||||||
function MobileNav({
|
function MobileNav({
|
||||||
isActive,
|
isActive,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
userPubkey,
|
||||||
|
userAvatar,
|
||||||
|
userName,
|
||||||
t,
|
t,
|
||||||
onCustomize,
|
onCustomize,
|
||||||
onMore,
|
|
||||||
fixedMapTab,
|
fixedMapTab,
|
||||||
middleTabs,
|
middleTabs,
|
||||||
}: {
|
}: {
|
||||||
isActive: (path: string) => boolean;
|
isActive: (path: string) => boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
userPubkey?: string;
|
||||||
|
userAvatar?: string;
|
||||||
|
userName?: string;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
onCustomize: () => void;
|
onCustomize: () => void;
|
||||||
onMore: () => void;
|
|
||||||
fixedMapTab: NavTab;
|
fixedMapTab: NavTab;
|
||||||
middleTabs: NavTab[];
|
middleTabs: NavTab[];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -394,7 +399,6 @@ function MobileNav({
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
to={tab.href}
|
to={tab.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
onTouchStart={startPress}
|
onTouchStart={startPress}
|
||||||
onTouchEnd={cancelPress}
|
onTouchEnd={cancelPress}
|
||||||
onTouchMove={cancelPress}
|
onTouchMove={cancelPress}
|
||||||
|
|
@ -428,7 +432,7 @@ function MobileNav({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'block w-full truncate text-center text-[10px] xs:text-[11px] leading-tight tracking-wide uppercase transition-all',
|
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
||||||
active ? 'font-extrabold' : 'font-semibold',
|
active ? 'font-extrabold' : 'font-semibold',
|
||||||
)}>
|
)}>
|
||||||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||||||
|
|
@ -453,84 +457,73 @@ function MobileNav({
|
||||||
{/* CUSTOM: 3 middle tabs */}
|
{/* CUSTOM: 3 middle tabs */}
|
||||||
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
||||||
|
|
||||||
{/* FIXED: More — the burger menu owns profile, login, wallet,
|
{/* FIXED: Profile / Login tab.
|
||||||
settings and every secondary section. */}
|
Logged in → your public profile (View Profile).
|
||||||
<button
|
Logged out → Settings (login lives there).
|
||||||
type="button"
|
Long-press / right-click still opens nav customization. */}
|
||||||
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"
|
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings';
|
||||||
aria-label={t('nav.more')}
|
const tabActive = isActive(profileHref);
|
||||||
>
|
return (
|
||||||
<span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
|
<Link
|
||||||
<MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
|
to={profileHref}
|
||||||
</span>
|
onTouchStart={startPress}
|
||||||
<span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
|
onTouchEnd={cancelPress}
|
||||||
{t('nav.more')}
|
onTouchMove={cancelPress}
|
||||||
</span>
|
onContextMenu={(e) => {
|
||||||
</button>
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact desktop status control. The old outlined pill + permanent ping read
|
|
||||||
* like an alert; this shows the current status as a quiet chip and falls back
|
|
||||||
* to a neutral "Status" affordance when none is set.
|
|
||||||
*/
|
|
||||||
function DesktopStatusButton({ onOpen }: { onOpen: () => void }) {
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: status } = useUserStatus(user?.pubkey);
|
|
||||||
const preset = status?.statusId
|
|
||||||
? SKATING_STATUSES.find((item) => item.id === status.statusId)
|
|
||||||
: null;
|
|
||||||
const label = preset ? t(preset.labelKey) : status?.status;
|
|
||||||
const emoji = status?.emoji || preset?.emoji;
|
|
||||||
const title = status && label
|
|
||||||
? `${label} — ${t('chromeUi.setYourStatus')}`
|
|
||||||
: t('chromeUi.setYourStatus');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onOpen}
|
|
||||||
title={title}
|
|
||||||
aria-label={title}
|
|
||||||
className={cn(
|
|
||||||
'h-9 w-9 shrink-0 rounded-full border border-white/10 p-0 text-gray-300',
|
|
||||||
'hover:border-green-500/30 hover:bg-green-500/10 hover:text-green-300',
|
|
||||||
status && 'border-green-500/20 bg-green-500/5 text-green-200',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{emoji ? (
|
|
||||||
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
|
|
||||||
) : (
|
|
||||||
<SmilePlus className="h-4 w-4 text-green-400/80" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children, noPadding }: LayoutProps) {
|
export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const location = useLocation();
|
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
|
// 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.
|
// padding even though it shares this one persistent layout with every route.
|
||||||
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
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 [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [mobileLanguageView, setMobileLanguageView] = 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
|
// 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.
|
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
|
||||||
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
||||||
|
|
@ -590,6 +583,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
|
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
|
||||||
const author = useAuthor(user?.pubkey || '');
|
const author = useAuthor(user?.pubkey || '');
|
||||||
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
||||||
|
const userAvatar = author.data?.metadata?.picture;
|
||||||
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
|
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
|
||||||
const {
|
const {
|
||||||
primaryTabs: desktopPrimaryTabs,
|
primaryTabs: desktopPrimaryTabs,
|
||||||
|
|
@ -624,13 +618,9 @@ 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">
|
<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
|
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
|
||||||
logo never gets squeezed into (or glowed over by) the nav. */}
|
logo never gets squeezed into (or glowed over by) the nav. */}
|
||||||
<Link
|
<Link to="/" className="flex items-center gap-3 group shrink-0">
|
||||||
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
|
|
||||||
className="flex items-center gap-3 group shrink-0"
|
|
||||||
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src="/images/logo.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
||||||
/>
|
/>
|
||||||
|
|
@ -791,7 +781,20 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{/* Status Quick Access - Desktop */}
|
{/* Status Quick Access - Desktop */}
|
||||||
{user && <DesktopStatusButton onOpen={() => setShowStatusDialog(true)} />}
|
{user && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
|
||||||
|
onClick={() => setShowStatusDialog(true)}
|
||||||
|
title={t('chromeUi.setYourStatus')}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -834,34 +837,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{/* Mobile Header */}
|
{/* 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">
|
<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">
|
<div className="flex items-center justify-between h-14 px-3 gap-3">
|
||||||
{showMobileBack && (
|
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
|
||||||
<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
|
<img
|
||||||
src="/images/logo.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
||||||
/>
|
/>
|
||||||
<span className={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
|
<span className="min-w-0 truncate font-display text-lg neon-red tracking-wide" style={{
|
||||||
textShadow: '0 0 8px #ff0040'
|
textShadow: '0 0 8px #ff0040'
|
||||||
}}>SKATESPOTS</span>
|
}}>SKATESPOTS</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1199,9 +1181,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
{children ?? <Outlet />}
|
||||||
{children ?? <Outlet />}
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - Desktop */}
|
{/* Footer - Desktop */}
|
||||||
|
|
@ -1249,7 +1229,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
<span>{t('chromeUi.notSponsoredBy')}</span>
|
<span>{t('chromeUi.notSponsoredBy')}</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<img
|
<img
|
||||||
src="/images/opensats.ico"
|
src="https://opensats.org/favicon.ico"
|
||||||
alt="OpenSats"
|
alt="OpenSats"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1266,8 +1246,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onMore={() => setMobileMenuOpen(true)}
|
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
|
userPubkey={user?.pubkey}
|
||||||
|
userAvatar={userAvatar}
|
||||||
|
userName={displayName}
|
||||||
t={t}
|
t={t}
|
||||||
onCustomize={() => setShowCustomizeNav(true)}
|
onCustomize={() => setShowCustomizeNav(true)}
|
||||||
fixedMapTab={fixedMapTab}
|
fixedMapTab={fixedMapTab}
|
||||||
|
|
@ -1280,14 +1262,12 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
|
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
|
||||||
{!hideSasquatch && (
|
<Suspense fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<LazySasquatchGuide
|
||||||
<LazySasquatchGuide
|
message={currentMessage}
|
||||||
message={currentMessage}
|
onDismiss={dismissCurrentMessage}
|
||||||
onDismiss={dismissCurrentMessage}
|
/>
|
||||||
/>
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Set Status Dialog — lazy (on-demand modal) */}
|
{/* Set Status Dialog — lazy (on-demand modal) */}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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,7 +20,6 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
import { PayWithSats } from '@/components/PayWithSats';
|
import { PayWithSats } from '@/components/PayWithSats';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
|
||||||
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -184,8 +183,6 @@ 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')}
|
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{isOwner && <PaymentReadinessNote />}
|
|
||||||
|
|
||||||
{mine.map((thread) => {
|
{mine.map((thread) => {
|
||||||
const latest = thread.latest;
|
const latest = thread.latest;
|
||||||
const other = latest.author === user.pubkey
|
const other = latest.author === user.pubkey
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,6 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
|
|
||||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||||
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
|
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
|
||||||
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
|
|
||||||
const listingUrl = `https://skate-spots.org${listingPath}`;
|
|
||||||
const contactSeller = () => navigate(
|
|
||||||
`/messages/${nip19.npubEncode(item.pubkey)}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
|
|
||||||
);
|
|
||||||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
|
|
@ -310,7 +305,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||||||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||||||
}`}
|
}`}
|
||||||
onClick={contactSeller}
|
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4" />
|
<MessageCircle className="h-4 w-4" />
|
||||||
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function MapFooterStrip() {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1.5 text-gray-500">
|
<span className="flex items-center gap-1.5 text-gray-500">
|
||||||
{t('chromeUi.notSponsoredBy')}
|
{t('chromeUi.notSponsoredBy')}
|
||||||
<img src="/images/opensats.ico" alt="OpenSats" className="h-3 w-3" />
|
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
|
||||||
<span className="font-medium text-gray-400">OpenSats</span>
|
<span className="font-medium text-gray-400">OpenSats</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,7 @@ export function PaymentReadinessNote() {
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
||||||
const lud16 = metadata?.lud16;
|
const lud16 = metadata?.lud16;
|
||||||
// PayWithSats falls back to the profile's lud16. A lud06/LNURL alone is
|
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
|
||||||
// not enough to settle a marketplace offer, so do not advertise readiness
|
|
||||||
// for a payment path checkout cannot actually use.
|
|
||||||
const hasLightning = isValidLightningAddress(lud16);
|
|
||||||
// Silent payment lives in a custom kind-0 field the app reads elsewhere.
|
// Silent payment lives in a custom kind-0 field the app reads elsewhere.
|
||||||
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
|
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
|
||||||
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);
|
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);
|
||||||
|
|
@ -61,7 +58,7 @@ export function PaymentReadinessNote() {
|
||||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
||||||
<span>
|
<span>
|
||||||
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
||||||
<Link to="/settings?section=profile" className="text-yellow-400 underline hover:text-yellow-300">
|
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
|
||||||
{t('marketUi.paymentReadiness.nostrProfile')}
|
{t('marketUi.paymentReadiness.nostrProfile')}
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
{t('marketUi.paymentReadiness.notReadyAfter')}
|
{t('marketUi.paymentReadiness.notReadyAfter')}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { BadgeCheck, ExternalLink } from 'lucide-react';
|
import { BadgeCheck, ExternalLink } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
|
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
|
||||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
||||||
|
|
||||||
/** The app logo is already served with the site. */
|
/** The app logo is already served with the site. */
|
||||||
const SKATESPOTS_LOGO = '/images/logo-icon.png';
|
const SKATESPOTS_LOGO = '/images/logo-icon.png';
|
||||||
|
|
@ -27,7 +26,6 @@ export function ProfileBadges({
|
||||||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||||
{badges.map((b, i) => {
|
{badges.map((b, i) => {
|
||||||
const app = isApp(b);
|
const app = isApp(b);
|
||||||
const safeUrl = b.url ? sanitizeUrl(b.url) : null;
|
|
||||||
const cls = cn(
|
const cls = cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
|
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
|
||||||
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
|
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
|
||||||
|
|
@ -43,17 +41,17 @@ export function ProfileBadges({
|
||||||
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[12rem] truncate">{b.label}</span>
|
<span className="max-w-[12rem] truncate">{b.label}</span>
|
||||||
{safeUrl && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
|
{b.url && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return safeUrl ? (
|
return b.url ? (
|
||||||
<a
|
<a
|
||||||
key={`${b.label}-${i}`}
|
key={`${b.label}-${i}`}
|
||||||
href={safeUrl}
|
href={b.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={safeUrl}
|
title={b.url}
|
||||||
className={cls}
|
className={cls}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { Activity, RefreshCw } from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useTranslation } from '@/contexts/language';
|
|
||||||
import { useRelayDivergence } from '@/hooks/useRelayDivergence';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings diagnostic: compare the community relay with the user's read relays.
|
|
||||||
* No merge, no vote, no data mutation.
|
|
||||||
*/
|
|
||||||
export function RelayDivergenceCard() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { result, checking, error, check } = useRelayDivergence();
|
|
||||||
|
|
||||||
const differences = result?.differences.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-black/90 border-white/10">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-bold text-white">
|
|
||||||
<Activity className="h-4 w-4 text-[#ccff00]" />
|
|
||||||
{t('settingsUi.relayList.relayDiagTitle')}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs leading-relaxed text-gray-400">
|
|
||||||
{t('settingsUi.relayList.relayDiagDesc')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void check()}
|
|
||||||
disabled={checking}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RefreshCw className={checking ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
|
|
||||||
{checking
|
|
||||||
? t('settingsUi.relayList.relayDiagChecking')
|
|
||||||
: t('settingsUi.relayList.relayDiagCheck')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-red-400">
|
|
||||||
{t('settingsUi.relayList.relayDiagFailed')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && !error && (
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
<p className={differences > 0 ? 'font-bold text-amber-300' : 'text-green-400'}>
|
|
||||||
{differences > 0
|
|
||||||
? t('settingsUi.relayList.relayDiagDiff', { count: differences })
|
|
||||||
: t('settingsUi.relayList.relayDiagAgree')}
|
|
||||||
</p>
|
|
||||||
{differences > 0 && (
|
|
||||||
<p className="text-gray-500">{t('settingsUi.relayList.relayDiagDetails')}</p>
|
|
||||||
)}
|
|
||||||
{result.errors.length > 0 && (
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{result.errors.length} {t('settingsUi.relayList.offline')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -22,12 +22,6 @@ import {
|
||||||
import { getLastLocation } from '@/lib/lastLocation';
|
import { getLastLocation } from '@/lib/lastLocation';
|
||||||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||||
import {
|
|
||||||
MAP_TILE_URL,
|
|
||||||
MAP_TILE_SUBDOMAINS,
|
|
||||||
MAP_TILE_CACHE_NAMESPACE,
|
|
||||||
MAP_TILE_ATTRIBUTION,
|
|
||||||
} from '@/lib/mapTiles';
|
|
||||||
import { calculateDistance } from '@/lib/sortOptions';
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
import {
|
import {
|
||||||
fetchRoute,
|
fetchRoute,
|
||||||
|
|
@ -604,13 +598,13 @@ export function RouteDialog({
|
||||||
minZoom: 2,
|
minZoom: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
createCachedTileLayer(MAP_TILE_URL, {
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: MAP_TILE_ATTRIBUTION,
|
attribution: '',
|
||||||
subdomains: MAP_TILE_SUBDOMAINS,
|
subdomains: 'abc',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
noWrap: true,
|
noWrap: true,
|
||||||
className: 'skatespots-dark-tiles',
|
className: 'skatespots-dark-tiles',
|
||||||
}, MAP_TILE_CACHE_NAMESPACE).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// Metric scale bar — genuinely useful on a route map (how long is that line?).
|
// Metric scale bar — genuinely useful on a route map (how long is that line?).
|
||||||
L.control.scale({ imperial: false, position: 'topleft' }).addTo(map);
|
L.control.scale({ imperial: false, position: 'topleft' }).addTo(map);
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { backupConfirmedKey } from '@/lib/backup';
|
import { backupConfirmedKey } from '@/lib/backup';
|
||||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { toast } from '@/hooks/useToast';
|
import { toast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,23 +23,23 @@ import { useTranslation } from '@/contexts/language';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SESSION_KEY = 'splash-shown-session';
|
const SESSION_KEY = 'splash-shown-session';
|
||||||
const MIN_MS = 380; // keep the brand beat, lose the artificial wait
|
const MIN_MS = 900; // choreography needs room to breathe
|
||||||
const WARM_GRACE_MS = 180; // a short breath after relays connect
|
const WARM_GRACE_MS = 550; // wait after connection for content to stream in
|
||||||
const MAX_MS = 900; // hard cap — reveal the shell even on a dead network
|
const MAX_MS = 2600; // hard cap — never block on a slow/offline network
|
||||||
const FADE_MS = 280; // iris-close exit
|
const FADE_MS = 480;
|
||||||
const READY_HOLD_MS = 120; // show the ready check, then move
|
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
||||||
|
|
||||||
const SPARKS = [
|
const SPARKS = [
|
||||||
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
|
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
|
||||||
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '20ms' },
|
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '90ms' },
|
||||||
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '40ms' },
|
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '180ms' },
|
||||||
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '60ms' },
|
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '260ms' },
|
||||||
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '80ms' },
|
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '340ms' },
|
||||||
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '100ms' },
|
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '430ms' },
|
||||||
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '120ms' },
|
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '520ms' },
|
||||||
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '140ms' },
|
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '610ms' },
|
||||||
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '160ms' },
|
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '700ms' },
|
||||||
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '180ms' },
|
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '790ms' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_KEYS = [
|
const STATUS_KEYS = [
|
||||||
|
|
@ -75,7 +75,7 @@ export function SplashScreen() {
|
||||||
if (!visible || ready) return;
|
if (!visible || ready) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
||||||
}, 380);
|
}, 640);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible, ready]);
|
}, [visible, ready]);
|
||||||
|
|
||||||
|
|
@ -84,8 +84,8 @@ export function SplashScreen() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || ready) return;
|
if (!visible || ready) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setProgress((p) => (p >= 85 ? p : p + Math.max(0.9, (85 - p) * 0.065)));
|
setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045)));
|
||||||
}, 60);
|
}, 90);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible, ready]);
|
}, [visible, ready]);
|
||||||
|
|
||||||
|
|
@ -155,24 +155,9 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
// The exit is an aperture-style iris closing into the logo. It reveals
|
opacity: leaving ? 0 : 1,
|
||||||
// the already-painted app from the edges without moving the map. Users
|
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
|
||||||
// who prefer reduced motion get a simple, quick fade instead.
|
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)`,
|
||||||
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)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
||||||
|
|
@ -204,22 +189,10 @@ export function SplashScreen() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div className="relative z-10 flex flex-col items-center px-8">
|
||||||
className="relative z-10 flex flex-col items-center px-8"
|
{/* The orange wheel mark rolls in, keeps a slow roll, and throws sparks */}
|
||||||
style={{
|
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
|
||||||
// Keep the mark visible all the way through the iris-close exit.
|
{/* Rotating conic ring — orange to match the wheel, with a lime kick */}
|
||||||
// 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
|
<div
|
||||||
className="absolute -inset-3 rounded-full"
|
className="absolute -inset-3 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -227,14 +200,15 @@ export function SplashScreen() {
|
||||||
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
|
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
|
||||||
opacity: ready ? 0.9 : 0.58,
|
opacity: ready ? 0.9 : 0.58,
|
||||||
filter: 'blur(7px)',
|
filter: 'blur(7px)',
|
||||||
transition: 'opacity 240ms ease',
|
transition: 'opacity 400ms ease',
|
||||||
|
...anim('splashSpin 3.2s linear infinite'),
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/* Mask ring interior so only a thin halo shows */}
|
{/* Mask ring interior so only a thin halo shows */}
|
||||||
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
|
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
|
||||||
|
|
||||||
{/* One-shot landing sparks, disabled for reduced-motion users. */}
|
{/* Sparks are decorative and disabled for reduced-motion users. */}
|
||||||
{!prefersReducedMotion && (
|
{!prefersReducedMotion && (
|
||||||
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
|
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||||
{SPARKS.map((spark, index) => (
|
{SPARKS.map((spark, index) => (
|
||||||
|
|
@ -253,16 +227,25 @@ export function SplashScreen() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bounce wrapper + rolling wheel. Nested transforms keep the spin
|
||||||
|
from fighting the entry animation. */}
|
||||||
<div
|
<div
|
||||||
style={anim('splashLogoDrop 380ms cubic-bezier(0.22,1,0.36,1) both')}
|
style={anim('splashLogoRollIn 720ms cubic-bezier(0.22,1,0.36,1) both')}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
src="/images/logo.png"
|
style={anim('splashHover 1.9s ease-in-out infinite')}
|
||||||
alt="SkateSpots"
|
className="relative"
|
||||||
draggable={false}
|
>
|
||||||
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
|
<div style={anim('splashWheelRoll 5.4s linear infinite')} className="relative">
|
||||||
/>
|
<img
|
||||||
|
src="/images/icon-1024.png"
|
||||||
|
alt="SkateSpots"
|
||||||
|
draggable={false}
|
||||||
|
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* Contact shadow / dust line under the wheel */}
|
{/* Contact shadow / dust line under the wheel */}
|
||||||
<div
|
<div
|
||||||
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
|
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
|
||||||
|
|
@ -271,15 +254,14 @@ export function SplashScreen() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wordmark */}
|
{/* Wordmark */}
|
||||||
<h1
|
<h1
|
||||||
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
||||||
style={{
|
style={{
|
||||||
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
||||||
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '0ms',
|
animationDelay: prefersReducedMotion ? undefined : '120ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
SKATESPOTS
|
SKATESPOTS
|
||||||
|
|
@ -289,8 +271,8 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
||||||
style={{
|
style={{
|
||||||
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '30ms',
|
animationDelay: prefersReducedMotion ? undefined : '220ms',
|
||||||
}}
|
}}
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
|
|
@ -301,7 +283,7 @@ export function SplashScreen() {
|
||||||
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
transition: 'width 240ms cubic-bezier(0.4,0,0.2,1)',
|
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
|
||||||
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -323,8 +305,8 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="mt-5 flex h-4 items-center gap-2"
|
className="mt-5 flex h-4 items-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '60ms',
|
animationDelay: prefersReducedMotion ? undefined : '320ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck, Trash2 } from 'lucide-react';
|
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
|
|
@ -23,7 +23,6 @@ import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
|
||||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||||||
|
|
@ -31,7 +30,6 @@ import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||||
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SpotCardProps {
|
interface SpotCardProps {
|
||||||
|
|
@ -50,13 +48,10 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||||||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
|
|
||||||
// Determine if this is a shop or a spot
|
// Determine if this is a shop or a spot
|
||||||
const isShop = spot.types.includes('shop');
|
const isShop = spot.types.includes('shop');
|
||||||
const isOwner = user?.pubkey === spot.pubkey;
|
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}`;
|
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).
|
// Shops that actually issue access passes get a badge (one shared, cached query).
|
||||||
|
|
@ -250,19 +245,7 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
{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 && (
|
{!isOwner && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -292,17 +275,6 @@ 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 */}
|
{/* Report Dialog */}
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
open={showReportDialog}
|
open={showReportDialog}
|
||||||
|
|
|
||||||