Update from source caccc893a
This commit is contained in:
parent
0bb0dbbf89
commit
079266367c
28 changed files with 623 additions and 74 deletions
12
.env.example
12
.env.example
|
|
@ -2,3 +2,15 @@
|
|||
# on the host page; never bake into the IIFE bundle.
|
||||
VITE_MAPBOX_TOKEN=pk.your_dev_token_here
|
||||
VITE_API_BASE_URL=https://pray.doxa.life
|
||||
|
||||
# Follow a shared branch while `bun run dev` runs (internal/dev-sync-plugin.mjs): fetch every N seconds and
|
||||
# fast-forward when this checkout is on that branch with no local edits; otherwise only report it at
|
||||
# /api/dev-sync. DX_SYNC_REMOTE is a remote name (origin) or a repo URL; leave it unset to turn this off.
|
||||
# DX_SYNC_REMOTE=origin
|
||||
# DX_SYNC_BRANCH=main
|
||||
# DX_SYNC_INTERVAL=5
|
||||
|
||||
# PocketBase for plugins (internal/backend-dev-plugin.mjs): `bun run dev` downloads the pinned binary once
|
||||
# and serves it at /api/pb, data in .pb/dev. DX_PB_URL uses a shared PocketBase instead of a local one.
|
||||
# DX_PB_PORT=8090
|
||||
# DX_PB_URL=
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -148,3 +148,5 @@ data-private/
|
|||
|
||||
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
|
||||
/backend/bin/
|
||||
# The local PocketBase's data (`bun run dev`, internal/backend-dev-plugin.mjs): per laptop, never committed.
|
||||
/.pb/
|
||||
|
|
|
|||
|
|
@ -36,6 +36,24 @@ ARG PB_SHA256=9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa
|
|||
RUN curl -fsSL -o /tmp/pb.zip "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" \
|
||||
&& echo "${PB_SHA256} /tmp/pb.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/pb.zip pocketbase -d /usr/local/bin && rm /tmp/pb.zip && chmod 755 /usr/local/bin/pocketbase
|
||||
# PocketBase team sign-in gate (a JS hook), baked here and pinned: the ONLY server-side hook that runs, in pb-dev.
|
||||
# It lives in the image, never in the pulled repo, so a plugin writer's push can't change what it lets in.
|
||||
# Changing it = edit these lines AND the SHA-256 below, then redeploy the image (an admin act).
|
||||
RUN mkdir -p /app/pb_hooks_gate && printf '%s\n' \
|
||||
'// Team sign-in gate: only members of OIDC_ALLOWED_GROUPS (the groups claim from the identity provider) may sign in here.' \
|
||||
'// Baked into the image by deployment/cloud/Dockerfile and pinned by SHA-256: never read from the pulled repo.' \
|
||||
'onRecordAuthWithOAuth2Request((e) => {' \
|
||||
' const allow = ($os.getenv("OIDC_ALLOWED_GROUPS") || "").split(",").map((s) => s.trim()).filter((s) => s)' \
|
||||
' const raw = (e.oAuth2User && e.oAuth2User.rawUser) || {}' \
|
||||
' const groups = []' \
|
||||
' if (raw.groups) for (let i = 0; i < raw.groups.length; i++) groups.push(String(raw.groups[i]))' \
|
||||
' if (!allow.length || !groups.some((g) => allow.indexOf(g) >= 0)) {' \
|
||||
' throw new ForbiddenError("This account is not in a group allowed to use this team backend.")' \
|
||||
' }' \
|
||||
' e.next()' \
|
||||
'}, "users")' \
|
||||
> /app/pb_hooks_gate/team-gate.pb.js \
|
||||
&& echo "2987055ba6067fc30d8162608da21c6c2b691fb6897a46d10b7075271a38f13d /app/pb_hooks_gate/team-gate.pb.js" | sha256sum -c -
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'set -u; export GIT_TERMINAL_PROMPT=0' \
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ With the sign-in on, the container also runs PocketBase and can publish a produc
|
|||
| Route | Who | What |
|
||||
|---|---|---|
|
||||
| `/` | signed-in | the dev server (Plugin Studio, hot reload) |
|
||||
| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only) | PocketBase **dev** |
|
||||
| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only). With `PB_OIDC_CLIENT_SECRET`, its `/api/` is public and PocketBase signs people in itself (see below); `_superusers` and system collections stay admin-only | PocketBase **dev**, the team's shared backend |
|
||||
| `/api-prod/api/` | **public** | PocketBase **prod**'s API; its dashboard is never exposed |
|
||||
| `/cdn/dx-maps/` | **public** | the last published build: `manifest.json` at a stable URL, CORS open for embeds |
|
||||
| `POST /admin/build` · `GET /admin/status` | `OIDC_ADMIN_GROUPS` only | build and publish the pulled commit |
|
||||
|
|
@ -65,6 +65,7 @@ there. Only then does it swap the new build in.
|
|||
|---|---|
|
||||
| `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard |
|
||||
| `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances |
|
||||
| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` |
|
||||
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https://<host>/api-prod`) |
|
||||
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
|
||||
| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy.
|
||||
// /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here)
|
||||
// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard
|
||||
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS
|
||||
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET,
|
||||
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
|
||||
// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET
|
||||
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
|
||||
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
|
|
@ -42,8 +44,13 @@ Bun.serve({
|
|||
}
|
||||
if (p.startsWith('/pb/')) {
|
||||
if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403)
|
||||
// The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these
|
||||
// paths to the public (its skip routes exclude names starting with _), so the groups header is its own.
|
||||
if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403)
|
||||
return proxy(req, url, '/pb/', 8090)
|
||||
}
|
||||
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
|
||||
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
||||
if (p === '/admin/status') return Response.json(last)
|
||||
if (p === '/admin/build') {
|
||||
if (req.method !== 'POST') return text('POST only', 405)
|
||||
|
|
|
|||
|
|
@ -102,13 +102,34 @@ for inst in dev prod; do
|
|||
&& echo "[pb-$inst] superuser set" || echo "[pb-$inst] superuser upsert failed"
|
||||
fi
|
||||
done
|
||||
# Server-side JS hooks OFF: point --hooksDir at a path that never exists, so a pb_hooks/ folder
|
||||
# added to the repo can't run code in either instance (v1 has no per-plugin isolation for hooks).
|
||||
# Server-side JS hooks from the REPO are OFF: --hooksDir never points into the pulled tree, so a pb_hooks/
|
||||
# folder pushed by a plugin writer can't run code (v1 has no per-plugin isolation for hooks). prod runs
|
||||
# none; dev runs only the team sign-in gate BAKED INTO THE IMAGE (Dockerfile, SHA-256 pinned).
|
||||
NOHOOKS=/var/empty/pb_hooks_disabled
|
||||
pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
|
||||
GATE=/app/pb_hooks_gate
|
||||
DEV_HOOKS=$NOHOOKS; [ -f "$GATE/team-gate.pb.js" ] && DEV_HOOKS=$GATE
|
||||
pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$DEV_HOOKS" &
|
||||
pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
|
||||
bun /app/repo/deployment/cloud/admin.mjs &
|
||||
|
||||
# TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https://<host>/pb), and PocketBase
|
||||
# itself signs people in through the same OIDC provider, members of OIDC_ALLOWED_GROUPS only (the baked
|
||||
# gate). Fails CLOSED: /pb/api/ is opened only when the gate is in place AND the OIDC setup succeeded;
|
||||
# otherwise pb-dev stays behind the site sign-in, as before. /pb/_/ (dashboard) and _superusers never open.
|
||||
PB_PUBLIC=()
|
||||
if [ -n "${PB_OIDC_CLIENT_SECRET:-}" ]; then
|
||||
for _ in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:8090/api/health && break; sleep 0.5; done
|
||||
PB_APP_URL="${PB_APP_URL:-$(printf '%s' "$OIDC_REDIRECT_URL" | sed -E 's#^([a-z]+://[^/]+).*#\1#')/pb}"
|
||||
if [ "$DEV_HOOKS" = "$GATE" ] && PB_APP_URL="$PB_APP_URL" bun /app/repo/deployment/cloud/pb-oidc.mjs; then
|
||||
PB_PUBLIC=(--skip-auth-route='^/pb/api/(health|realtime|oauth2-redirect|batch)$'
|
||||
--skip-auth-route='^/pb/api/files/'
|
||||
--skip-auth-route='^/pb/api/collections/[^_/][^/]*/')
|
||||
echo "[pb-dev] team API open at /pb/api/ (sign-in through PocketBase; dashboard stays gated)"
|
||||
else
|
||||
echo "[pb-dev] team sign-in NOT configured: /pb/ stays behind the site sign-in" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
VITE_HOST=127.0.0.1 VITE_PORT=5174 bun x vite --config "$CONFIG" &
|
||||
oauth2-proxy \
|
||||
--provider=oidc \
|
||||
|
|
@ -128,6 +149,7 @@ oauth2-proxy \
|
|||
--upstream=http://127.0.0.1:5175/admin/ \
|
||||
--skip-auth-route='^/cdn/' \
|
||||
--skip-auth-route='^/api-prod/api/' \
|
||||
"${PB_PUBLIC[@]}" \
|
||||
--http-address="0.0.0.0:$PORT" \
|
||||
--reverse-proxy=true \
|
||||
--cookie-secure=true \
|
||||
|
|
|
|||
41
deployment/cloud/pb-oidc.mjs
Normal file
41
deployment/cloud/pb-oidc.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// pb-oidc.mjs — make the team PocketBase (pb-dev) sign people in through the team's OIDC provider.
|
||||
// Run by entry.sh at every start, against PocketBase on localhost, as the superuser (env, never printed).
|
||||
// users: sign-in ONLY through OIDC (password sign-in off); the group check is the image's baked hook.
|
||||
// The client secret comes from the environment; nothing here, or in PocketBase's data, is in git.
|
||||
const env = process.env
|
||||
const PB = env.PB_LOCAL_URL || 'http://127.0.0.1:8090'
|
||||
const need = (k) => env[k] || (console.error(`[pb-oidc] ${k} is not set: team sign-in NOT configured`), process.exit(1))
|
||||
|
||||
const call = async (method, path, body, token) => {
|
||||
const r = await fetch(PB + path, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) }, body: body && JSON.stringify(body) })
|
||||
const j = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${j.message || ''}`)
|
||||
return j
|
||||
}
|
||||
|
||||
const issuer = need('OIDC_ISSUER').replace(/\/$/, '')
|
||||
const disc = env.PB_OIDC_DISCOVERY_JSON ? JSON.parse(env.PB_OIDC_DISCOVERY_JSON)
|
||||
: await (await fetch(issuer + '/.well-known/openid-configuration')).json()
|
||||
const { token } = await call('POST', '/api/collections/_superusers/auth-with-password',
|
||||
{ identity: need('PB_SUPERUSER_EMAIL'), password: need('PB_SUPERUSER_PASSWORD') })
|
||||
|
||||
await call('PATCH', '/api/collections/users', {
|
||||
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
mappedFields: { id: '', name: 'name', username: '', avatarURL: '' },
|
||||
providers: [{
|
||||
name: 'oidc',
|
||||
displayName: env.OIDC_DISPLAY_NAME || 'Team sign-in',
|
||||
clientId: env.PB_OIDC_CLIENT_ID || 'dx-maps-pb-dev',
|
||||
clientSecret: need('PB_OIDC_CLIENT_SECRET'),
|
||||
authURL: disc.authorization_endpoint,
|
||||
tokenURL: disc.token_endpoint,
|
||||
userInfoURL: disc.userinfo_endpoint,
|
||||
pkce: true,
|
||||
extra: {},
|
||||
}],
|
||||
},
|
||||
}, token)
|
||||
if (env.PB_APP_URL) await call('PATCH', '/api/settings', { meta: { appURL: env.PB_APP_URL } }, token)
|
||||
console.log(`[pb-oidc] users sign in through ${issuer} only (password sign-in off)`)
|
||||
52
deployment/cloud/pb-signin-check.html
Normal file
52
deployment/cloud/pb-signin-check.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!doctype html>
|
||||
<!-- One-click check of the team PocketBase sign-in (/admin/pb-signin-check, behind the site sign-in).
|
||||
The same flow as the PocketBase SDK's authWithOAuth2: subscribe to @oauth2 over /pb/api/realtime, open the
|
||||
provider in a popup, exchange the code. The token is shown as a result only and never stored. -->
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Team PocketBase sign-in check</title>
|
||||
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem}button{font:inherit;padding:.6rem 1.2rem;cursor:pointer}#out{margin-top:1.5rem;padding:1rem;border-radius:.5rem;background:#f3f3f3;white-space:pre-wrap}.ok{background:#e6f6ea!important}.no{background:#fdecec!important}</style>
|
||||
<h1>Team PocketBase sign-in check</h1>
|
||||
<p>Signs you in to the team PocketBase (<code>/pb</code>) through the team account, and shows who it thinks you are. Nothing is stored in this page.</p>
|
||||
<button id="go">Sign in to the team PocketBase</button>
|
||||
<div id="out">Not tried yet.</div>
|
||||
<script type="module">
|
||||
const PB = location.origin + '/pb'
|
||||
const out = document.getElementById('out')
|
||||
const show = (text, cls) => { out.textContent = text; out.className = cls || '' }
|
||||
document.getElementById('go').onclick = async () => {
|
||||
const popup = window.open('', 'pb-oauth2', 'width=520,height=720') // opened in the click, so it is not blocked
|
||||
show('Connecting…')
|
||||
try {
|
||||
const methods = await (await fetch(PB + '/api/collections/users/auth-methods')).json()
|
||||
const provider = (methods.oauth2?.providers || []).find((p) => p.name === 'oidc')
|
||||
if (!provider) throw new Error('The team PocketBase has no team sign-in configured.')
|
||||
const es = new EventSource(PB + '/api/realtime')
|
||||
const clientId = await new Promise((ok, fail) => {
|
||||
es.addEventListener('PB_CONNECT', (e) => ok(JSON.parse(e.data).clientId))
|
||||
es.onerror = () => fail(new Error('Could not open the realtime connection.'))
|
||||
})
|
||||
const sub = await fetch(PB + '/api/realtime', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, subscriptions: ['@oauth2'] }) })
|
||||
if (!sub.ok) throw new Error('Could not subscribe to the sign-in result (' + sub.status + ').')
|
||||
const redirectURL = PB + '/api/oauth2-redirect'
|
||||
const url = new URL(provider.authURL + encodeURIComponent(redirectURL))
|
||||
url.searchParams.set('state', clientId)
|
||||
show('Finish signing in in the popup…')
|
||||
popup.location.href = url.toString()
|
||||
const result = await new Promise((ok) => es.addEventListener('@oauth2', (e) => ok(JSON.parse(e.data))))
|
||||
es.close()
|
||||
try { popup.close() } catch {}
|
||||
if (result.state !== clientId) throw new Error('The sign-in state did not match; try again.')
|
||||
if (result.error || !result.code) throw new Error('The team account provider said: ' + (result.error || 'no code'))
|
||||
const r = await fetch(PB + '/api/collections/users/auth-with-oauth2', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'oidc', code: result.code, codeVerifier: provider.codeVerifier, redirectURL }) })
|
||||
const j = await r.json()
|
||||
if (!r.ok) return show('REFUSED by the team PocketBase (' + r.status + '): ' + (j.message || 'no message'), 'no')
|
||||
const groups = j.meta?.rawUser?.groups
|
||||
show('Signed in to the team PocketBase as ' + (j.record?.email || j.meta?.email || '(no email)') +
|
||||
', groups: ' + (Array.isArray(groups) ? groups.join(', ') : '(none)') + (j.meta?.isNew ? '\n(first sign-in: your team user was created)' : ''), 'ok')
|
||||
} catch (e) {
|
||||
try { popup.close() } catch {}
|
||||
show('Did not complete: ' + e.message, 'no')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
173
documentation/HOWTO-join-the-table.md
Normal file
173
documentation/HOWTO-join-the-table.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# How to join the table
|
||||
|
||||
You want to build a map. Someone told you there is a repo, a dev server, and a thing called PocketBase, and
|
||||
now you are staring at three of them wondering which one you are supposed to touch.
|
||||
|
||||
Short answer: **you work on your laptop, you land your work on main, and a shared server shows everyone what
|
||||
landed.** That is the whole shape. The rest of this page is what each of those three is for, and the five
|
||||
commands you actually type.
|
||||
|
||||
> **This page is about working alongside other people.** If you want to know how to build a plugin itself —
|
||||
> the files, the build, getting it live — read
|
||||
> [develop-a-plugin-and-see-it-live.md](./develop-a-plugin-and-see-it-live.md) first and come back here when
|
||||
> you are ready to share your work.
|
||||
|
||||
---
|
||||
|
||||
## The three places
|
||||
|
||||
### 1. Your laptop — the workshop
|
||||
|
||||
This is where you build. Everything is local and fast, and nothing you do here can affect anyone else.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
You get the Plugin Studio, hot reload on your own edits, and a local database with your own test data. Break
|
||||
it, reset it, fill it with nonsense — it is yours.
|
||||
|
||||
### 2. `main` — the truth
|
||||
|
||||
When your work is ready, it goes to `main`. That is the one place everybody agrees on. Nothing is "real"
|
||||
until it is there.
|
||||
|
||||
### 3. The shared dev server — the table
|
||||
|
||||
A server watches `main` and reloads about every 15 seconds. That is where the team sees each other's work without
|
||||
anyone sending anyone a zip file. You do not edit here. You never need to log into it to do your job.
|
||||
|
||||
> **The rule that makes this work:** one plugin folder per person. You edit `plugin-designs/<your-plugin>/`
|
||||
> and nobody else does. Everything below depends on that.
|
||||
|
||||
---
|
||||
|
||||
## Getting your work onto main
|
||||
|
||||
Three commands, in this order, every time:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "what you changed"
|
||||
git pull --rebase origin main
|
||||
git push origin HEAD:main
|
||||
```
|
||||
|
||||
If the push is rejected, someone landed first. Run the middle command again and push again. That is not an
|
||||
error — it is the normal case, and it is worth knowing how normal.
|
||||
|
||||
**We measured it.** In a ten-writer stress test at a far faster edit rate than a human works at:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pushes rejected, then retried | **95%** |
|
||||
| merge conflicts | **0** |
|
||||
| retries needed per edit | 1.1 average, 3 at worst |
|
||||
|
||||
Nearly every push got rejected. **Not one caused a conflict.** That is the one-folder-each rule paying off:
|
||||
you are all pushing to the same branch, so you collide on *who goes first* — but never on the same lines of
|
||||
code, because you are never in the same file. A rejected push costs you one extra command. It does not cost
|
||||
you an afternoon untangling someone else's work.
|
||||
|
||||
If you *do* hit a real conflict, it means two people edited one file. Stop and talk to the other person;
|
||||
that is a coordination problem, not a git problem.
|
||||
|
||||
---
|
||||
|
||||
## Seeing your teammates' work on your laptop (optional)
|
||||
|
||||
By default your laptop only reloads on *your* edits. If you want teammates' landed work to arrive
|
||||
automatically while `bun run dev` is running, copy `.env.example` to `.env.local` at the repo root and set:
|
||||
|
||||
```
|
||||
DX_SYNC_REMOTE=origin
|
||||
```
|
||||
|
||||
That is the only line you need. `.env.local` is never committed, so it stays yours.
|
||||
|
||||
> **Either a remote name or a full repo URL works.** `origin` is what almost everyone wants. A URL —
|
||||
> `https://…/your-repo.git` — works too and saves you a `git remote add`; if the repo is private it uses
|
||||
> the git credentials your laptop already has. Leave it unset and the whole feature is off.
|
||||
>
|
||||
> If you do put a token in the URL (`https://user:token@…`), it is never written to the log or shown at
|
||||
> `/api/dev-sync`.
|
||||
|
||||
Two optional dials, if you want them:
|
||||
|
||||
| | what it does | default |
|
||||
|---|---|---|
|
||||
| `DX_SYNC_BRANCH` | which branch to follow | `main` |
|
||||
| `DX_SYNC_INTERVAL` | seconds between checks | `5` (minimum 2) |
|
||||
|
||||
**It only pulls when it is completely safe to.** All three must be true: you are on that branch, you have no
|
||||
unsaved edits, and you have no local commits. If any one of them is not, it does not touch your files —
|
||||
it tells you instead, in the dev server log:
|
||||
|
||||
```
|
||||
main is 3 commits ahead … pull when ready
|
||||
```
|
||||
|
||||
So it cannot surprise you mid-thought. You can also check that any time at `/api/dev-sync`.
|
||||
|
||||
---
|
||||
|
||||
## The database, and the one thing that surprises people
|
||||
|
||||
There are three databases and you only ever touch one.
|
||||
|
||||
| Where | What is in it | Who touches it |
|
||||
|---|---|---|
|
||||
| Your laptop | your own sandbox data | you, freely |
|
||||
| The shared dev server | the team's shared test data | the team |
|
||||
| Production | what the public sees | nobody directly |
|
||||
|
||||
**You never publish a database.** Not yours, not anyone's. What travels is the *shape* — the tables and
|
||||
fields — as migration files committed to the repo, in `backend/pocketbase/pb_migrations/`.
|
||||
|
||||
So adding a field is a normal change: you edit, you commit the migration, you push it like any other file,
|
||||
and it goes through review with everything else. When the person who publishes runs the publish step, the
|
||||
migrations apply.
|
||||
|
||||
This is deliberate. A database rule that opens something to the public arrives as **a commit someone can
|
||||
read** rather than as a setting somebody clicked at midnight. If you have ever inherited a system where the
|
||||
live config did not match the repo, you already know why.
|
||||
|
||||
**You do not have to set up the database at all.** The first time you run `bun run dev` it downloads and
|
||||
starts one for you, on your laptop, with its data in `.pb/`. You can open its admin screen from the dev
|
||||
server at `/api/pb/_/`.
|
||||
|
||||
The only time you touch this is if the log says port 8090 is already in use — then set `DX_PB_PORT` to
|
||||
something else in `.env.local`.
|
||||
|
||||
---
|
||||
|
||||
## One more thing to set
|
||||
|
||||
The maps need a Mapbox token. It is the one credential you have to ask the team for:
|
||||
|
||||
```
|
||||
VITE_MAPBOX_TOKEN=<ask the team>
|
||||
```
|
||||
|
||||
That, plus `DX_SYNC_REMOTE=origin` if you want it, is the whole of `.env.local`.
|
||||
|
||||
---
|
||||
|
||||
## The five things to remember
|
||||
|
||||
1. **Build on your laptop.** It is fast and it is yours.
|
||||
2. **One plugin folder per person.** This is what keeps ten people out of each other's way.
|
||||
3. **`pull --rebase`, then push.** A rejected push is normal — 95% of them were, and none became conflicts.
|
||||
4. **The shared server is for looking, not editing.**
|
||||
5. **Schema is a commit, never a click.** You never publish a database.
|
||||
|
||||
---
|
||||
|
||||
## When something is wrong
|
||||
|
||||
- **"Your push was rejected"** — normal. `git pull --rebase origin main`, push again.
|
||||
- **A real merge conflict** — two people edited one file. Talk to them; the one-folder rule slipped.
|
||||
- **Your change is not on the shared server** — it reloads about every 15 seconds, not instantly. Check your
|
||||
push actually landed: `git log origin/main -1`.
|
||||
- **The shared server shows an old version of your plugin** — you are probably looking at what is on `main`,
|
||||
not what is on your laptop. Those are different on purpose.
|
||||
|
|
@ -18,11 +18,28 @@
|
|||
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync, renameSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { connect } from 'node:net'
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
|
||||
export const DEV_BACKENDS = [
|
||||
// PocketBase, the backend for plugins that need one: the SAME pinned version as the cloud container
|
||||
// (deployment/cloud/Dockerfile), SHA-256 from the release's checksums.txt. Downloaded on the first
|
||||
// `bun run dev` into backend/bin (gitignored); its data lives in .pb/dev (gitignored).
|
||||
const PB_VERSION = '0.40.4'
|
||||
const PB_SHA256 = {
|
||||
linux_amd64: '9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa',
|
||||
linux_arm64: '86095bf8ed9345954f0d2bf0a5fb9b57584ae60b77ebf3b6cd23a8003a3fd418',
|
||||
darwin_amd64: '052906521f09f6f23405cd804c930d2b7a1f8eee06d39b85723b5054b2acb4e4',
|
||||
darwin_arm64: 'eeb619ea4f8a06421daedb946d133bed269fea334a760941d147f76befc25ebc',
|
||||
}
|
||||
|
||||
// name → how to run it. `route` is proxied to `port` on 127.0.0.1, or to `remote` when that is set.
|
||||
// `env` is vite's loadEnv (so .env.local counts), falling back to process.env.
|
||||
export function devBackends(env = process.env) {
|
||||
const pbPort = Number(env.DX_PB_PORT || 8090)
|
||||
return [
|
||||
{
|
||||
name: 'rudder-engine',
|
||||
bin: 'backend/bin/rudder-engine',
|
||||
|
|
@ -37,12 +54,88 @@ export const DEV_BACKENDS = [
|
|||
// binary built by an earlier engine-on deploy is still there — it would offer to record a server's mic.
|
||||
disabled: () => process.env.RUDDER_ENGINE === '0',
|
||||
},
|
||||
]
|
||||
{
|
||||
// Hooks off (a hooks dir that never exists): only the migrations in git shape the schema, as in the cloud.
|
||||
// DX_PB_URL points this laptop at a shared PocketBase instead (e.g. the cloud pb-dev); none starts here.
|
||||
name: 'pocketbase',
|
||||
bin: 'backend/bin/pocketbase',
|
||||
args: ['serve', '--http', `127.0.0.1:${pbPort}`, '--dir', '.pb/dev', '--migrationsDir', 'backend/pocketbase/pb_migrations', '--hooksDir', '.pb/hooks-off'],
|
||||
build: 'it downloads on the next `bun run dev` (or set DX_PB_URL)',
|
||||
fetch: fetchPocketBase,
|
||||
portEnv: 'DX_PB_PORT',
|
||||
route: '/api/pb',
|
||||
rewrite: (p) => p.replace(/^\/api\/pb/, '') || '/',
|
||||
port: pbPort,
|
||||
remote: env.DX_PB_URL || '',
|
||||
offline: (res) => {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ status: 503, message: 'PocketBase is not running on this computer (yet). See the dev server log.' }))
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function fetchPocketBase(root, logger) {
|
||||
const plat = { 'linux-x64': 'linux_amd64', 'linux-arm64': 'linux_arm64', 'darwin-x64': 'darwin_amd64', 'darwin-arm64': 'darwin_arm64' }[`${process.platform}-${process.arch}`]
|
||||
if (!plat) {
|
||||
logger.warn(`[pocketbase] no pinned build for ${process.platform}-${process.arch}: put PocketBase ${PB_VERSION} at backend/bin/pocketbase, or set DX_PB_URL`)
|
||||
return false
|
||||
}
|
||||
const name = `pocketbase_${PB_VERSION}_${plat}.zip`
|
||||
const dir = resolve(root, 'backend/bin')
|
||||
const part = resolve(dir, `.pocketbase.part`)
|
||||
logger.info(`[pocketbase] downloading ${name} (first run only)`)
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const r = await fetch(`https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/${name}`)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
const sha = createHash('sha256').update(buf).digest('hex')
|
||||
if (sha !== PB_SHA256[plat]) throw new Error(`checksum mismatch (${sha}): refusing to use it`)
|
||||
writeFileSync(part, unzipOne(buf, 'pocketbase')) // not every laptop has `unzip`
|
||||
chmodSync(part, 0o755)
|
||||
renameSync(part, resolve(dir, 'pocketbase'))
|
||||
return true
|
||||
} catch (e) {
|
||||
logger.warn(`[pocketbase] download failed (${e.message}); /api/pb answers 503 until the next \`bun run dev\``)
|
||||
return false
|
||||
} finally {
|
||||
rmSync(part, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// One file out of a (non-zip64) zip, via its central directory; the zip's SHA-256 was checked before this.
|
||||
function unzipOne(buf, want) {
|
||||
const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
|
||||
if (end < 0) throw new Error('not a zip')
|
||||
for (let i = 0, p = buf.readUInt32LE(end + 16); i < buf.readUInt16LE(end + 10); i++) {
|
||||
const method = buf.readUInt16LE(p + 10), size = buf.readUInt32LE(p + 20)
|
||||
const nameLen = buf.readUInt16LE(p + 28), local = buf.readUInt32LE(p + 42)
|
||||
if (buf.toString('utf8', p + 46, p + 46 + nameLen) === want) {
|
||||
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
||||
const data = buf.subarray(start, start + size)
|
||||
if (method === 8) return inflateRawSync(data)
|
||||
if (method === 0) return data
|
||||
throw new Error(`unsupported zip method ${method}`)
|
||||
}
|
||||
p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32)
|
||||
}
|
||||
throw new Error(`${want} not in the zip`)
|
||||
}
|
||||
|
||||
// Another `bun run dev` (another worktree) may already hold the port: say so once instead of crash-looping.
|
||||
const portInUse = (port) => new Promise((ok) => {
|
||||
const s = connect({ host: '127.0.0.1', port }, () => { s.destroy(); ok(true) })
|
||||
s.on('error', () => ok(false))
|
||||
})
|
||||
|
||||
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
|
||||
export function devBackendProxy() {
|
||||
export function devBackendProxy(env = process.env) {
|
||||
// ws: a backend may take a WebSocket (rudder's /stream carries the browser's microphone on a server deployment).
|
||||
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false, ws: true }]))
|
||||
return Object.fromEntries(devBackends(env).map((b) => [b.route, {
|
||||
target: b.remote || `http://127.0.0.1:${b.port}`, changeOrigin: !!b.remote, ws: true,
|
||||
...(b.rewrite ? { rewrite: b.rewrite } : {}),
|
||||
}]))
|
||||
}
|
||||
|
||||
const REG = (globalThis.__dxDevBackends ||= { procs: new Map(), exitHooked: false, stopping: false })
|
||||
|
|
@ -55,7 +148,7 @@ function stopAll() {
|
|||
|
||||
function start(root, b, logger, restarts = 0) {
|
||||
const tag = `[${b.name}]`
|
||||
const child = spawn(resolve(root, b.bin), [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const child = spawn(resolve(root, b.bin), b.args || [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const entry = { child, restarts, logger }
|
||||
REG.procs.set(b.name, entry)
|
||||
const out = (d) => String(d).trim().split('\n').forEach((l) => l && entry.logger.info(`${tag} ${l}`))
|
||||
|
|
@ -78,7 +171,8 @@ function start(root, b, logger, restarts = 0) {
|
|||
logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
|
||||
}
|
||||
|
||||
export function backendDevPlugin(root) {
|
||||
export function backendDevPlugin(root, env = process.env) {
|
||||
const backends = devBackends(env)
|
||||
return {
|
||||
name: 'dx-backend-dev',
|
||||
apply: 'serve',
|
||||
|
|
@ -88,9 +182,10 @@ export function backendDevPlugin(root) {
|
|||
// well-formed answers instead: /events streams ONE status event saying the engine is offline (the
|
||||
// plugin renders its own "engine not running" state from it), anything else returns an empty JSON
|
||||
// list. Registered here, so it runs before vite's proxy.
|
||||
for (const b of DEV_BACKENDS) {
|
||||
for (const b of backends) {
|
||||
server.middlewares.use(b.route, (req, res, next) => {
|
||||
if (isAlive(b.name)) return next()
|
||||
if (b.remote || isAlive(b.name)) return next()
|
||||
if (b.offline) return b.offline(res)
|
||||
const status = { engine: 'offline', expected: !!(b.expected && b.expected()), error: 'The desktop engine is not running.' }
|
||||
if ((req.url || '').startsWith('/events')) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
|
||||
|
|
@ -114,7 +209,11 @@ export function backendDevPlugin(root) {
|
|||
process.once('SIGINT', () => { stopAll(); process.exit(0) })
|
||||
process.once('SIGTERM', () => { stopAll(); process.exit(0) })
|
||||
}
|
||||
for (const b of DEV_BACKENDS) {
|
||||
for (const b of backends) {
|
||||
if (b.remote) {
|
||||
logger.info(`[${b.name}] not started here: ${b.route} → ${b.remote}`)
|
||||
continue
|
||||
}
|
||||
if (isAlive(b.name)) {
|
||||
REG.procs.get(b.name).logger = logger // log through the restarted server
|
||||
logger.info(`[${b.name}] still running → ${b.route} (kept across the dev-server restart)`)
|
||||
|
|
@ -124,11 +223,25 @@ export function backendDevPlugin(root) {
|
|||
logger.info(`[backend] ${b.name} switched off here (${b.name === 'rudder-engine' ? 'RUDDER_ENGINE=0' : 'disabled'})`)
|
||||
continue
|
||||
}
|
||||
const launch = async () => {
|
||||
if (b.portEnv && await portInUse(b.port)) {
|
||||
logger.warn(`[${b.name}] port ${b.port} is already in use (another \`bun run dev\`?): not started. Set ${b.portEnv} to use another port.`)
|
||||
return
|
||||
}
|
||||
if (!REG.stopping && !isAlive(b.name)) start(root, b, logger)
|
||||
}
|
||||
if (!existsSync(resolve(root, b.bin))) {
|
||||
if (b.fetch) {
|
||||
REG.fetching ||= new Set()
|
||||
if (REG.fetching.has(b.name)) continue // a vite restart mid-download: the first one finishes it
|
||||
REG.fetching.add(b.name)
|
||||
b.fetch(root, logger).then((ok) => { REG.fetching.delete(b.name); if (ok) launch() })
|
||||
continue
|
||||
}
|
||||
logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
|
||||
continue
|
||||
}
|
||||
start(root, b, logger)
|
||||
launch()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
89
internal/dev-sync-plugin.mjs
Normal file
89
internal/dev-sync-plugin.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* internal/dev-sync-plugin.mjs — `bun run dev` follows a shared branch, so a laptop sees teammates' landings.
|
||||
*
|
||||
* WHY: ten people each running `bun run dev` should see what the others landed on main without anyone
|
||||
* typing `git pull`: the dev server fast-forwards and Vite hot-reloads the changed files. It is the local
|
||||
* form of the cloud container's pull loop (deployment/cloud/entry.sh), MINUS the `reset --hard`: a laptop
|
||||
* holds someone's unsaved work, so this NEVER merges over local edits, never stashes, never resets.
|
||||
*
|
||||
* OPT-IN, ONE switch in .env.local: DX_SYNC_REMOTE = a remote name (origin) OR a repo URL (https://…/x.git),
|
||||
* e.g. to watch what cloud agents push. DX_SYNC_BRANCH defaults to main, DX_SYNC_INTERVAL to 5 seconds.
|
||||
* Unset = off: plain hot reload of local edits, as always. Every interval it fetches <remote> <branch>
|
||||
* (a URL needs no `git remote add`; private repos use your normal git credentials) and fast-forwards ONLY when
|
||||
* this checkout is on that branch, no tracked file is modified, and `git merge --ff-only` agrees.
|
||||
* Otherwise it logs once per new upstream commit ("main is N commits ahead, pull when ready") and reports
|
||||
* the state at /api/dev-sync for a Studio badge.
|
||||
*
|
||||
* DEV ONLY (`apply: 'serve'`).
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
const git = (root, args) => new Promise((ok) => execFile('git', args,
|
||||
{ cwd: root, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
|
||||
(e, out, err) => ok({ ok: !e, out: String(out || '').trim(), err: String(err || e?.message || '').trim().split('\n')[0] })))
|
||||
|
||||
// What the log and /api/dev-sync show: never the credentials of a https://user:token@host/… URL.
|
||||
const shown = (remote) => remote.replace(/\/\/[^/@]*@/, '//')
|
||||
const label = (remote, branch) => (remote.includes('/') ? `${branch} of ${shown(remote)}` : `${remote}/${branch}`)
|
||||
|
||||
// One timer per `bun run dev`, kept across Vite's own restarts (the config module is re-imported then).
|
||||
const REG = (globalThis.__dxDevSync ||= { timer: null, running: false, status: { enabled: false } })
|
||||
|
||||
export async function syncOnce(root, remote, branch, log = { info() {}, warn() {} }) {
|
||||
if (REG.running) return REG.status
|
||||
REG.running = true
|
||||
try {
|
||||
const src = label(remote, branch)
|
||||
const s = { enabled: true, remote: shown(remote), branch, checkedAt: new Date().toISOString() }
|
||||
const f = await git(root, ['fetch', '-q', remote, branch])
|
||||
if (!f.ok) return (REG.status = { ...s, state: 'fetch-failed', message: `could not fetch ${src}: ${shown(f.err)}` })
|
||||
const up = (await git(root, ['rev-parse', 'FETCH_HEAD'])).out
|
||||
const head = (await git(root, ['rev-parse', 'HEAD'])).out
|
||||
const cur = (await git(root, ['symbolic-ref', '-q', '--short', 'HEAD'])).out
|
||||
const behind = Number((await git(root, ['rev-list', '--count', `HEAD..${up}`])).out || 0)
|
||||
const ahead = Number((await git(root, ['rev-list', '--count', `${up}..HEAD`])).out || 0)
|
||||
const dirty = (await git(root, ['status', '--porcelain', '--untracked-files=no'])).out !== ''
|
||||
Object.assign(s, { upstream: up, behind, ahead, dirty, currentBranch: cur || '(detached)' })
|
||||
if (behind === 0) return (REG.status = { ...s, state: 'up-to-date', message: `up to date with ${src}` })
|
||||
const why = cur !== branch ? `this checkout is on ${cur || 'a detached HEAD'}, not ${branch}`
|
||||
: dirty ? 'you have local edits'
|
||||
: ahead ? `you have ${ahead} local commit(s) that are not on ${src}` : ''
|
||||
const m = why ? null : await git(root, ['merge', '--ff-only', '-q', up])
|
||||
if (why || !m.ok) {
|
||||
const message = `${branch} is ${behind} commit(s) ahead; ${why || `fast-forward refused (${m.err})`}, so nothing was merged. Pull when ready.`
|
||||
if (REG.status.upstream !== up || REG.status.state !== 'behind') log.warn(`[dev-sync] ${message}`)
|
||||
return (REG.status = { ...s, state: 'behind', message })
|
||||
}
|
||||
log.info(`[dev-sync] fast-forwarded ${head.slice(0, 8)} → ${up.slice(0, 8)} (${behind} commit(s) from ${src})`)
|
||||
return (REG.status = { ...s, behind: 0, state: 'up-to-date', lastMerge: s.checkedAt, message: `fast-forwarded ${behind} commit(s) from ${src}` })
|
||||
} finally {
|
||||
REG.running = false
|
||||
}
|
||||
}
|
||||
|
||||
export function devSyncPlugin(root, env = process.env) {
|
||||
const remote = env.DX_SYNC_REMOTE || ''
|
||||
const branch = env.DX_SYNC_BRANCH || 'main'
|
||||
const every = Math.max(2, Number(env.DX_SYNC_INTERVAL) || 5)
|
||||
return {
|
||||
name: 'dx-dev-sync',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/dev-sync', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
||||
res.end(JSON.stringify(REG.status))
|
||||
})
|
||||
clearInterval(REG.timer)
|
||||
if (!remote) {
|
||||
REG.status = { enabled: false, message: 'off: set DX_SYNC_REMOTE (a remote name like origin, or a repo URL) in .env.local to follow a shared branch' }
|
||||
return
|
||||
}
|
||||
const log = server.config.logger
|
||||
log.info(`[dev-sync] following ${label(remote, branch)} every ${every}s (fast-forward only, never over local edits)`)
|
||||
const tick = () => syncOnce(root, remote, branch, log)
|
||||
REG.timer = setInterval(tick, every * 1000)
|
||||
REG.timer.unref?.()
|
||||
tick()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -472,7 +472,7 @@ useShadowStyles(
|
|||
'.gm-adm1{margin-top:8px;padding-top:7px;border-top:1px solid rgba(0,0,0,.12)}' +
|
||||
'.gm-adm1-h{font-weight:600;font-size:12px;margin-bottom:5px}' +
|
||||
'.gm-seg{display:inline-flex;border:1px solid rgba(0,0,0,.2);border-radius:6px;overflow:hidden;margin-bottom:6px}' +
|
||||
'.gm-seg button{font:inherit;font-size:11px;padding:3px 10px;border:0;background:transparent;color:inherit;cursor:pointer}' +
|
||||
'.gm-seg button{font:inherit;font-size:11px;padding:3px 10px;min-height:24px;border:0;background:transparent;color:inherit;cursor:pointer}' + // 24px: the AA touch-target minimum, at every width
|
||||
'.gm-seg button.on{background:#08519c;color:#fff}' +
|
||||
'.gm-adm1-t{width:100%;border-collapse:collapse;font-size:12px;margin:4px 0 6px}' +
|
||||
'.gm-adm1-t th,.gm-adm1-t td{padding:3px 4px;text-align:end}' +
|
||||
|
|
|
|||
|
|
@ -84,9 +84,13 @@ const SRC_R = 'cp-r', SRC_U = 'cp-u'
|
|||
const fc = (features) => ({ type: 'FeatureCollection', features })
|
||||
const pt = (g) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: { id: g.pgid, pop: g.pop, b: g.believers } })
|
||||
function setSrc(m, id, d) { const s = m.getSource(id); if (s) s.setData(d); else m.addSource(id, { type: 'geojson', data: d }) }
|
||||
let repaintPending = false
|
||||
function repaint() {
|
||||
const m = map.value
|
||||
if (!m || !m.isStyleLoaded?.()) return
|
||||
if (!m) return
|
||||
// isStyleLoaded() is also false while TILES load (MapLibre), so a data load that lands then used to
|
||||
// be dropped for good: the start view showed no dots at all (rega, upgn6). Try again once idle.
|
||||
if (!m.isStyleLoaded?.()) { if (!repaintPending) { repaintPending = true; m.once('idle', () => { repaintPending = false; repaint() }) } return }
|
||||
const b = active.value
|
||||
setSrc(m, SRC_U, fc(b ? b.unreached.map(pt) : []))
|
||||
setSrc(m, SRC_R, fc(b ? b.reached.map(pt) : []))
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ function select(g) { if (g && !g.reached) { nbState.pgid = g.pgid; query.value =
|
|||
// The neighbor in focus: a dot click picks its card, a card click flies to its dot. Both ways, so the
|
||||
// map and the brief never disagree about which neighbor you are looking at.
|
||||
const activeId = ref(null)
|
||||
// Hover label: 7,000 red dots are anonymous until clicked, so name the one under the pointer.
|
||||
const hover = ref(null) // { x, y, text }
|
||||
function hoverAt(ev, text) { hover.value = text ? { x: ev.point.x, y: ev.point.y, text } : null }
|
||||
watch(() => nbState.pgid, () => { activeId.value = null })
|
||||
function focusNeighbor(n, fly = true) {
|
||||
activeId.value = n.g.pgid
|
||||
|
|
@ -143,9 +146,13 @@ function ensureLayers(m) {
|
|||
function applyDim(m) {
|
||||
if (m.getLayer('nb-u')) m.setPaintProperty('nb-u', 'circle-opacity', selected.value ? 0.18 : 0.55)
|
||||
}
|
||||
let repaintPending = false
|
||||
function repaint() {
|
||||
const m = map.value
|
||||
if (!m || !m.isStyleLoaded?.()) return
|
||||
if (!m) return
|
||||
// isStyleLoaded() is also false while TILES load (MapLibre), so a data load that lands then used to
|
||||
// be dropped for good: the start view showed no dots at all (rega, upgn6). Try again once idle.
|
||||
if (!m.isStyleLoaded?.()) { if (!repaintPending) { repaintPending = true; m.once('idle', () => { repaintPending = false; repaint() }) } return }
|
||||
ensureLayers(m)
|
||||
}
|
||||
function frame() {
|
||||
|
|
@ -189,9 +196,12 @@ onMounted(async () => {
|
|||
const n = neighbors.value.find((x) => x.g.pgid === id)
|
||||
if (n) focusNeighbor(n, false)
|
||||
})
|
||||
const byId = (id) => data.value?.groups.find((g) => g.pgid === id)
|
||||
const label = (g) => g && `${g.name} · ${g.country} · ${g.langName}`
|
||||
for (const id of ['nb-u', 'nb-n']) {
|
||||
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
|
||||
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
|
||||
m.on('mousemove', id, (ev) => hoverAt(ev, label(byId(ev.features?.[0]?.properties?.id))))
|
||||
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = ''; hover.value = null })
|
||||
}
|
||||
})
|
||||
// A later style reload (a theme switch) drops our layers; put them back.
|
||||
|
|
@ -242,6 +252,7 @@ useShadowStyles(`
|
|||
.mb-same { font:700 10px/1 inherit; color:#7a3e00; background:#fdeedd; border-radius:999px; padding:3px 6px; margin-left:4px; }
|
||||
.mb-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
|
||||
.mb-maperr { position:absolute; right:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
|
||||
.mb-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.mb-legend { position:absolute; right:12px; bottom:28px; z-index:15; background:rgba(255,255,255,.95); border:1px solid #d9dee6; border-radius:10px; padding:8px 10px; font-size:12px; }
|
||||
.mb-legend div { display:flex; align-items:center; gap:7px; padding:2px 0; }
|
||||
@media (max-width:760px) {
|
||||
|
|
@ -255,6 +266,7 @@ useShadowStyles(`
|
|||
<div class="mb-root">
|
||||
<div ref="mapContainer" class="mb-map"></div>
|
||||
<div v-if="mapError" class="mb-maperr" role="status">{{ mapError }}</div>
|
||||
<div v-if="hover" class="mb-hover" :style="{ left: hover.x + 12 + 'px', top: hover.y + 12 + 'px' }">{{ hover.text }}</div>
|
||||
|
||||
<div class="mb-legend" :aria-label="$t('neighbor.mob.legend')">
|
||||
<div><span class="mb-dot" :style="{ background: UNREACHED }"></span>{{ $t('neighbor.mob.legendUnreached') }}</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
"bundle": "cities-internet",
|
||||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"output": "dx-maps/cities-internet/cities-internet.js",
|
||||
"integrity": "sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ",
|
||||
"integrity": "sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9",
|
||||
"elements": [
|
||||
"cities-internet-map"
|
||||
],
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1261,7 +1261,7 @@
|
|||
"bundle": "upg-neighbor",
|
||||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
|
||||
"integrity": "sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F",
|
||||
"integrity": "sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc",
|
||||
"elements": [
|
||||
"upg-neighbor"
|
||||
],
|
||||
|
|
@ -1279,7 +1279,7 @@
|
|||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
"bundle": "cities-internet",
|
||||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"output": "dx-maps/cities-internet/cities-internet.js",
|
||||
"integrity": "sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ",
|
||||
"integrity": "sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9",
|
||||
"elements": [
|
||||
"cities-internet-map"
|
||||
],
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1261,7 +1261,7 @@
|
|||
"bundle": "upg-neighbor",
|
||||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
|
||||
"integrity": "sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F",
|
||||
"integrity": "sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc",
|
||||
"elements": [
|
||||
"upg-neighbor"
|
||||
],
|
||||
|
|
@ -1279,7 +1279,7 @@
|
|||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
|||
import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs'
|
||||
import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs'
|
||||
import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs'
|
||||
import { devSyncPlugin } from './internal/dev-sync-plugin.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs'
|
||||
|
|
@ -121,7 +122,7 @@ export default defineConfig(({ command, mode }) => {
|
|||
// port. `--host` on the command line still overrides this for a deliberate LAN session.
|
||||
// Local plugin backends (backend/, e.g. rudder-engine) are started by backendDevPlugin and reached
|
||||
// through these same-origin routes, so a plugin calls /api/<name>/… in dev exactly as on desktop.
|
||||
server: { host: '127.0.0.1', proxy: devBackendProxy() },
|
||||
server: { host: '127.0.0.1', proxy: devBackendProxy(env) },
|
||||
define: {
|
||||
// ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message
|
||||
// with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string
|
||||
|
|
@ -162,7 +163,8 @@ export default defineConfig(({ command, mode }) => {
|
|||
vue({ customElement: true }),
|
||||
cssInjectedByJsPlugin(),
|
||||
autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''),
|
||||
backendDevPlugin(__dirname),
|
||||
backendDevPlugin(__dirname, env),
|
||||
devSyncPlugin(__dirname, env),
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@map': resolve(__dirname, 'library') },
|
||||
|
|
|
|||
Loading…
Reference in a new issue