Integrate main into bundler-2/work
# Conflicts: # public/doxa-maps/index.html
18
README.md
|
|
@ -73,6 +73,20 @@ the Research Gallery.
|
|||
|
||||
<sub>✅ works today · 🧪 proven, not yet in production. Every plugin builds into the same folder, and that folder is what all five methods ship — so a capability never limits where a plugin can go.</sub>
|
||||
|
||||
### The Experimental shelf
|
||||
|
||||
**New plugins the Research Gallery picks up by itself** — each builds and embeds like any other,
|
||||
and graduates to a named tab when it's ready.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="documentation/readme-images/exp-wall-of-people-groups.png" alt="Wall of People Groups"><br><b>Wall of People Groups</b><br><sub>all 2,107 Doxa people groups as photo cards on one zoomable prayer wall, with a pray dialog</sub></td>
|
||||
<td width="50%" valign="top"><img src="documentation/readme-images/exp-prayer-walk-planner.png" alt="Prayer Walk Planner"><br><b>Prayer Walk Planner</b><br><sub>plan a prayer walk through a zone, open it full screen from any embed, and print a walking map that stays sharp when zoomed</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Also on the shelf: UPG neighbor, all people groups, country posters, the vision card, and the PPLR data maps.</sub>
|
||||
|
||||
<details>
|
||||
<summary><b>How one build becomes five ways to ship — the "map cruncher"</b></summary>
|
||||
|
||||
|
|
@ -149,7 +163,9 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
├─ UPG neighbor upg-neighbor capability 3
|
||||
├─ all people groups all-people-groups capability 1
|
||||
├─ country posters country-posters capability 4
|
||||
└─ vision card doxa-vision-card capability 2
|
||||
├─ vision card doxa-vision-card capability 2
|
||||
├─ PPLR data maps pplr-data-maps capability 3
|
||||
└─ Wall of People Groups wall-of-people-groups capability 3
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
documentation/readme-images/exp-prayer-walk-planner.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
documentation/readme-images/exp-wall-of-people-groups.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 891 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -1,23 +1,23 @@
|
|||
{
|
||||
"cap1-single-element": {
|
||||
"bundle": "all-people-groups",
|
||||
"sha256": "0ede35b98a92ea2a56fa5b6302eb3dd827d9de4c636f82f663f9c0f4d8b24890"
|
||||
"sha256": "c990929277d9aa5fd0d4ef78362f7b43e328a7c073ac6bf97aadfde5cb942de9"
|
||||
},
|
||||
"cap2-adoption": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap2-engagement": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap2-prayer": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap3-monolithic-research": {
|
||||
"bundle": "doxa-research-map",
|
||||
"sha256": "3391075b32dd710b7f810e3097074a4d453c7720a6963e101ddec99ad4186d29"
|
||||
"sha256": "b849fb71c4a96810d840d9f2dfc208c3e13648429e0ae1a1c5784693e6e5774f"
|
||||
},
|
||||
"cap4-upg100-list": {
|
||||
"bundle": "my-upg-100-list",
|
||||
|
|
@ -43,17 +43,25 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
|
||||
},
|
||||
"exp-prayer-walk-planner": {
|
||||
"bundle": "prayer-walk-planner",
|
||||
"sha256": "cee5f2806bd5cc8a7fa4c49db200df643d07cf303fd75ee739e606dd9ee93aee"
|
||||
},
|
||||
"exp-wall-of-people-groups": {
|
||||
"bundle": "wall-of-people-groups",
|
||||
"sha256": "ba718e082f77f50d5775ebf43007385b7293f8099e2ca4d79e8ad1753ac4133f"
|
||||
},
|
||||
"prod-big-picture": {
|
||||
"bundle": "dashboard-plus-grid",
|
||||
"sha256": "d3eb3468e6f42c3346885a665102dd10965a98a5e045f50b0d15c14f52a05e56"
|
||||
"sha256": "e90739f622678db4c421c76855a5c39882aabd4ed8dbdd65cbe1385a45c67edc"
|
||||
},
|
||||
"prod-cities-internet": {
|
||||
"bundle": "cities-internet",
|
||||
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
|
||||
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
|
||||
},
|
||||
"prod-cities-internet-phone": {
|
||||
"bundle": "cities-internet",
|
||||
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
|
||||
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
|
||||
},
|
||||
"prod-gospel-resources": {
|
||||
"bundle": "gospel-resources",
|
||||
|
|
@ -65,15 +73,15 @@
|
|||
},
|
||||
"prod-prayer-phone": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"prod-regions": {
|
||||
"bundle": "regions-dashboard",
|
||||
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
|
||||
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
|
||||
},
|
||||
"prod-regions-phone": {
|
||||
"bundle": "regions-dashboard",
|
||||
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
|
||||
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
|
||||
},
|
||||
"prod-research-gallery-phone": {
|
||||
"bundle": "doxa-maps-spa",
|
||||
|
|
@ -81,10 +89,10 @@
|
|||
},
|
||||
"prod-simple-research": {
|
||||
"bundle": "research-map-plus-grid",
|
||||
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
|
||||
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
|
||||
},
|
||||
"prod-simple-research-phone": {
|
||||
"bundle": "research-map-plus-grid",
|
||||
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
|
||||
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,3 +168,42 @@ export function requirePathInThisCheckout(file, repo, label) {
|
|||
console.log(`${label}: note — ${file} is outside any checkout, so which build produced it cannot be checked.`)
|
||||
return { ok: true, why: 'outside any checkout' }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The same guard for gates aimed at a VITE DEV SERVER, which has no built manifest to fingerprint.
|
||||
*
|
||||
* A dev server serves this repo's SOURCE, so identity is the source itself: ask it for a few of this
|
||||
* checkout's library files with Vite's `?raw` query (the file's exact text, as an ES module string)
|
||||
* and compare byte for byte. A dev server rooted in another worktree with different code answers
|
||||
* differently, or 403s the path — either way it is NOT MEASURED (exit 75). One serving identical code
|
||||
* is, by definition, measuring the same thing. Unreachable is left to the gate's own handling.
|
||||
* Remote (non-loopback) targets are not checked, as in requireThisTree.
|
||||
*/
|
||||
const DEV_FINGERPRINT = ['library/layouts/VerticalTabsLayout.vue', 'library/composables/useMapInstance.js', 'library/composables/useShadowStyles.js']
|
||||
export async function requireDevServesThisCheckout(base, repo, label, files = DEV_FINGERPRINT) {
|
||||
let origin
|
||||
try { origin = new URL(String(base)).origin } catch { return { ok: true, why: 'not a URL' } }
|
||||
if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(new URL(origin).hostname)) return { ok: true, why: 'remote target' }
|
||||
const refuse = (why) => {
|
||||
console.log(`${label}: NOT MEASURED — the dev server at ${origin} is not serving THIS checkout's source (${why}).`)
|
||||
console.log(' Start `bun x vite --port <yours> --strictPort` in this worktree and pass that URL.')
|
||||
process.exit(75)
|
||||
}
|
||||
for (const rel of files) {
|
||||
let local
|
||||
try { local = readFileSync(resolve(repo, rel), 'utf8') } catch { continue }
|
||||
const r = await fetch(`${origin}/${rel}?raw`, { signal: AbortSignal.timeout(8000) }).catch(() => null)
|
||||
if (!r) return { ok: false, why: 'unreachable' }
|
||||
if (!r.ok) refuse(`${rel} → HTTP ${r.status}`)
|
||||
// A plain fetch (not a module import) gets the file's TEXT served directly; an import-style
|
||||
// request gets it wrapped as `export default "…"`. Accept both shapes — the first version only
|
||||
// parsed the wrapped one and refused this very checkout's own dev server (measured 2026-09-25).
|
||||
const body = await r.text()
|
||||
const m = body.match(/^export default ("(?:[^"\\]|\\.)*")/s)
|
||||
let served = body
|
||||
if (m) { try { served = JSON.parse(m[1]) } catch { served = null } }
|
||||
if (served !== local) refuse(`${rel} differs`)
|
||||
}
|
||||
return { ok: true, why: 'same source' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,16 @@ done
|
|||
|
||||
# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite
|
||||
# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it.
|
||||
#
|
||||
# ★ `{ exec 9>…; } 2>/dev/null`, NEVER `exec 9>… 2>/dev/null`. A bare exec applies EVERY redirection
|
||||
# on its line to this shell for good, so the old form sent stderr to /dev/null for the rest of the
|
||||
# script AND for the gate exec'd below. Measured 2026-09-25: every gate run through this wrapper lost
|
||||
# its stderr — verify-map-paint exited 1 on two leaves with its FAIL lines (console.error) gone, and
|
||||
# this script's own "not measuring" message vanished too. The braces scope the 2>/dev/null to the
|
||||
# open alone; fd 9 still persists, which is the only thing the exec is for.
|
||||
i=1
|
||||
while [ "$i" -le "$SLOTS" ]; do
|
||||
exec 9>"$(slot_path "$i")" 2>/dev/null || true
|
||||
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
|
||||
if flock -n 9; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
|
@ -72,7 +79,7 @@ deadline=$(( $(date +%s) + WAIT_S ))
|
|||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
i=1
|
||||
while [ "$i" -le "$SLOTS" ]; do
|
||||
exec 9>"$(slot_path "$i")" 2>/dev/null || true
|
||||
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
|
||||
if flock -n 9; then exec "$@"; fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
|
|
|||
97
internal/check-basemap-labels-localized.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-basemap-labels-localized — the BASEMAP's place names follow the reader's language, on BOTH
|
||||
* engines (card BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP; the code under test is
|
||||
* library/maps/localize-labels.js, 319064a1 / 4839f879).
|
||||
*
|
||||
* USAGE: node internal/check-basemap-labels-localized.mjs [--dir <tree root>] [--leaf bundle/profile]
|
||||
* Serves THIS checkout's public/ on an OS-assigned port (so it cannot measure another tree),
|
||||
* or --dir (used for the negative control: a tree whose bundle predates the fix).
|
||||
* Exit 0 = both engines localize · 1 = an engine does not · 2 = could not measure.
|
||||
*
|
||||
* WHAT IS READ — the RENDERED label, not our own guess at an expression:
|
||||
* Mapbox Standard keeps its labels in the IMPORTED basemap, invisible to a plain
|
||||
* queryRenderedFeatures. The 'place-labels' featureset of the 'basemap' import returns
|
||||
* each country label with the `name` the engine drew (setLanguage localizes it).
|
||||
* MapLibre queryRenderedFeatures returns each symbol's EVALUATED text-field, i.e. the text drawn.
|
||||
* WHAT IS ASSERTED, per engine, between ?lang=en and ?lang=ru on the same view (30°E 45°N, z3):
|
||||
* Mapbox known countries flip: Germany→Германия, Ukraine→Украина, Turkey→Турция, Egypt→Египет
|
||||
* (at least two of them seen in both runs).
|
||||
* MapLibre for the country labels (identified by their name:en property), the ru run draws the
|
||||
* feature's name:ru, and the en run does not. (English on MapLibre restores the style's
|
||||
* ORIGINAL label, OSM's Latin name — 'Deutschland', not 'Germany' — so no English word
|
||||
* is expected there; the flip to Cyrillic is the evidence.)
|
||||
*
|
||||
* NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels
|
||||
* existed), served with --dir, FAILS on both engines.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
|
||||
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
|
||||
const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' }
|
||||
|
||||
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
const up = await fetch(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html`).then((r) => r.ok).catch(() => false)
|
||||
if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) }
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
async function labels(provider, lang) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
await page.goto(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html?lang=${lang}${provider === 'maplibre' ? '&provider=maplibre' : ''}`, { waitUntil: 'networkidle' })
|
||||
const FIND = `(() => { const f = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m } } return null }; return f(document) })()`
|
||||
let ok = false
|
||||
for (let i = 0; i < 60 && !ok; i++) { await page.waitForTimeout(500); ok = await page.evaluate(`(() => { const m = ${FIND}; return !!(m && m.isStyleLoaded && m.isStyleLoaded()) })()`).catch(() => false) }
|
||||
if (!ok) { await page.close(); return null }
|
||||
await page.evaluate(`(() => { const m = ${FIND}; m.jumpTo({ center: [30, 45], zoom: 3 }) })()`)
|
||||
await page.waitForTimeout(7000)
|
||||
const out = await page.evaluate(`(() => { const m = ${FIND}
|
||||
if (${JSON.stringify(provider)} === 'mapbox') {
|
||||
let fs = []; try { fs = m.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } }) } catch (e) { return { error: e.message } }
|
||||
return { names: fs.filter((f) => f.properties && f.properties.class === 'country').map((f) => f.properties.name) }
|
||||
}
|
||||
const text = (tf) => (tf && Array.isArray(tf.sections)) ? tf.sections.map((s) => s.text).join('') : (typeof tf === 'string' ? tf : '')
|
||||
const feats = m.queryRenderedFeatures().filter((f) => f.layer && f.layer.type === 'symbol' && /country/.test(f.layer.id) && f.properties['name:en'])
|
||||
return { countries: feats.map((f) => ({ en: f.properties['name:en'], ru: f.properties['name:ru'] || '', drawn: text(f.layer.layout && f.layer.layout['text-field']) })) }
|
||||
})()`)
|
||||
await page.close()
|
||||
return out
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const provider of ['mapbox', 'maplibre']) {
|
||||
const en = await labels(provider, 'en'), ru = await labels(provider, 'ru')
|
||||
if (!en || !ru || en.error || ru.error) { results.push({ provider, measured: false, why: (en && en.error) || (ru && ru.error) || 'map never finished its style' }); continue }
|
||||
if (provider === 'mapbox') {
|
||||
const seen = Object.keys(PAIRS).filter((k) => en.names.includes(k))
|
||||
const flipped = seen.filter((k) => ru.names.includes(PAIRS[k]))
|
||||
// MEASURED = the engine drew enough country labels to judge. Not "saw two known English names":
|
||||
// without localization Mapbox Standard draws a different default form, and that absence IS the
|
||||
// defect — the first version of this line reported the pre-fix build as NOT MEASURED instead of
|
||||
// FAIL (68 country labels drawn, none English), which would let the gate pass by abstaining.
|
||||
results.push({ provider, measured: en.names.length >= 5 && ru.names.length >= 5, ok: seen.length >= 2 && flipped.length === seen.length,
|
||||
detail: `en saw ${seen.join(', ') || '(none)'}; ru drew ${flipped.map((k) => PAIRS[k]).join(', ') || '(none of them)'} (${en.names.length}/${ru.names.length} country labels)` })
|
||||
} else {
|
||||
const byEn = (arr) => new Map(arr.map((c) => [c.en, c]))
|
||||
const E = byEn(en.countries), R = byEn(ru.countries)
|
||||
const common = [...R.keys()].filter((k) => E.has(k) && R.get(k).ru)
|
||||
const localized = common.filter((k) => R.get(k).drawn === R.get(k).ru && E.get(k).drawn !== R.get(k).ru)
|
||||
results.push({ provider, measured: common.length >= 2, ok: common.length >= 2 && localized.length === common.length,
|
||||
detail: `${localized.length}/${common.length} countries drawn in Russian under ru and not under en` + (common[0] ? ` — e.g. ${common[0]}: en "${E.get(common[0]).drawn}" → ru "${R.get(common[0]).drawn}"` : '') })
|
||||
}
|
||||
}
|
||||
await browser.close(); srv.kill()
|
||||
|
||||
for (const r of results) console.log(` ${r.provider.padEnd(9)} ${!r.measured ? 'NOT MEASURED' : r.ok ? 'ok ' : 'FAIL'} ${r.detail || r.why}`)
|
||||
if (results.some((r) => r.measured && !r.ok)) { console.log('BASEMAP-LABELS: FAIL — a basemap does not follow the reader\'s language on every engine.'); process.exit(1) }
|
||||
if (results.some((r) => !r.measured)) { console.log('BASEMAP-LABELS: NOT MEASURED — an engine gave too few labels to judge.'); process.exit(2) }
|
||||
console.log(`BASEMAP-LABELS: PASS — ${LEAF}'s basemap place names follow the reader's language on Mapbox AND MapLibre.`)
|
||||
185
internal/check-experimental-shelf.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-experimental-shelf — "all experimental plugins working", as a measurement.
|
||||
*
|
||||
* THE SHELF is derived, never listed: every bundle in the built manifest that the Research Gallery
|
||||
* would discover into its Experimental folder, i.e. not a curated tab and not in NOT_EXPERIMENTAL.
|
||||
* Both sets are read from plugin-designs/doxa-maps-spa/doxa-maps-spa.vue, so a bundle that joins or
|
||||
* leaves the shelf changes what this gate measures without an edit here.
|
||||
*
|
||||
* PER BUNDLE, PER ENGINE (mapbox, maplibre), on TWO surfaces: its own leaf page (A) and its tab
|
||||
* inside the Research Gallery (D). A surface passes when:
|
||||
* - the element mounted with content (its shadow tree has real nodes),
|
||||
* - 0 page errors and 0 failed requests (4xx/5xx; Mapbox telemetry and favicons excluded),
|
||||
* - no loading text is left on screen (Loading / Adding / Computing),
|
||||
* - and, for every MAP on the page: its 'load' fired, and it DRAWS its own data, meaning at
|
||||
* least one rendered feature on a layer fed by a GeoJSON source. That is the lesson from the
|
||||
* PPLR check: 'load' alone is not loaded, and an empty GeoJSON source still counts as a
|
||||
* source.
|
||||
* Maps are found without touching any bundle: an init script wraps the engine's Map class as the
|
||||
* engine script defines it, so every map the page constructs is recorded.
|
||||
*
|
||||
* IDENTITY: the gate serves THIS checkout's public/ itself, on a port the OS hands out, so it
|
||||
* cannot grade another worktree's tree (the fixed-port trap internal/_serves-this-tree.mjs names).
|
||||
*
|
||||
* SELF-TEST (--self-test, and always first unless --no-self-test): the first shelf bundle's script
|
||||
* is replaced in flight by one that throws. That bundle MUST fail on both surfaces; if it does not,
|
||||
* the gate is blind and exits 3 before grading anything.
|
||||
*
|
||||
* Usage: node internal/check-experimental-shelf.mjs [--engines mapbox,maplibre] [--only a,b]
|
||||
* [--no-self-test] [--self-test]
|
||||
* Exit: 0 all pass · 1 a bundle failed · 3 self-test could not see a broken bundle · 2 setup.
|
||||
* Run browser gates through internal/browser-slot.sh.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const ENGINES = arg('--engines', 'mapbox,maplibre').split(',')
|
||||
const ONLY = arg('--only', '') ? arg('--only', '').split(',') : null
|
||||
const SELF_TEST = !argv.includes('--no-self-test')
|
||||
const WAIT_MS = Number(process.env.SHELF_WAIT_MS || 75000)
|
||||
|
||||
const { chromium } = await import(resolve(ROOT, 'node_modules/playwright/index.mjs'))
|
||||
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8'))
|
||||
const spa = readFileSync(resolve(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue'), 'utf8')
|
||||
const cfg = JSON.parse(readFileSync(resolve(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8'))
|
||||
const TK = process.env.MAPBOX_TOKEN || cfg.token || ''
|
||||
|
||||
// ── the shelf, derived from the Gallery's own rules ───────────────────────────
|
||||
const notExp = new Set((spa.match(/const NOT_EXPERIMENTAL = new Set\(\[([\s\S]*?)\]\)/) || [, ''])[1].match(/'([a-z0-9-]+)'/g)?.map(s => s.slice(1, -1)) || [])
|
||||
const curated = new Set([...spa.matchAll(/bundle:\s*'([a-z0-9-]+)'/g)].map(m => m[1]))
|
||||
if (!notExp.size || !curated.size) { console.error('check-experimental-shelf: could not read NOT_EXPERIMENTAL / curated tabs from doxa-maps-spa.vue'); process.exit(2) }
|
||||
const shelf = manifest.bundles
|
||||
.filter(b => !notExp.has(b.bundle) && !curated.has(b.bundle) && (b.profiles || []).length)
|
||||
.filter(b => !ONLY || ONLY.includes(b.bundle))
|
||||
.map(b => {
|
||||
// the entry profile the Gallery opens: a map profile, else the one named like the bundle, else the first
|
||||
const p = b.profiles.find(x => x.isMap) || b.profiles.find(x => x.profile === b.bundle) || b.profiles[0]
|
||||
return { bundle: b.bundle, profile: p.profile, isMap: !!p.isMap, tag: p.element || (b.elements || [])[0] || b.bundle }
|
||||
})
|
||||
if (!shelf.length) { console.error('check-experimental-shelf: the shelf is empty'); process.exit(2) }
|
||||
|
||||
// ── SEEDS: bundles whose data is the USER'S, so a fresh browser correctly shows none ─────────
|
||||
// Seeded before load, then required to be DRAWN, which is stronger than exempting them. Printed on
|
||||
// every run with the reason, so a seed can never quietly hide a regression.
|
||||
const SEED = {
|
||||
'prayer-walk-planner': {
|
||||
why: 'walk zones are user-drawn and kept in localStorage; a fresh browser has none, so one known zone is seeded and must be drawn',
|
||||
keyPrefix: 'prayer-walk-planner:v1:',
|
||||
value: JSON.stringify([{ id: 'shelf-seed', title: 'Shelf check zone', coords: [[0, 10], [20, 10], [20, 25], [0, 25]] }]),
|
||||
},
|
||||
}
|
||||
|
||||
// ── our own server, on a port nobody else can be holding ──────────────────────
|
||||
const port = await new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', 'public'], { cwd: ROOT, stdio: 'ignore' })
|
||||
const reap = () => { try { srv.kill() } catch (_) {} }
|
||||
process.on('exit', reap); for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => { reap(); process.exit(130) })
|
||||
const BASE = `http://127.0.0.1:${port}/doxa-maps`
|
||||
for (let i = 0; i < 40; i++) { try { if ((await fetch(BASE + '/manifest.json')).ok) break } catch (_) {} await new Promise(r => setTimeout(r, 250)) }
|
||||
|
||||
// ── every Map the page builds, recorded without touching the bundle ───────────
|
||||
const RECORD_MAPS = () => {
|
||||
window.__shelfMaps = []
|
||||
for (const name of ['mapboxgl', 'maplibregl']) {
|
||||
let v
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
get() { return v },
|
||||
set(val) {
|
||||
if (val && val.Map && !val.Map.__shelfWrapped) {
|
||||
const Orig = val.Map
|
||||
class Recorded extends Orig { constructor(...a) { super(...a); window.__shelfMaps.push(this); this.once('load', () => { this.__shelfLoaded = true }) } }
|
||||
Recorded.__shelfWrapped = true
|
||||
try { val.Map = Recorded } catch (_) { try { Object.defineProperty(val, 'Map', { value: Recorded }) } catch (_) {} }
|
||||
}
|
||||
v = val
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
const PROBE = (tag) => `(() => {
|
||||
const acc = []; const w = (r) => { for (const el of r.querySelectorAll('*')) { acc.push(el); if (el.shadowRoot) w(el.shadowRoot) } }; w(document)
|
||||
// CONTENT is the plugin's own subtree, not the page: a small card on a bare leaf and a card inside the
|
||||
// Gallery's chrome must be judged the same way.
|
||||
const host = acc.find(e => e.tagName === ${JSON.stringify('__TAG__')}.toUpperCase())
|
||||
let own = 0, sized = false
|
||||
if (host) { const inner = []; const w2 = (r) => { for (const el of r.querySelectorAll('*')) { inner.push(el); if (el.shadowRoot) w2(el.shadowRoot) } }; w2(host.shadowRoot || host); own = inner.length
|
||||
const hr = host.getBoundingClientRect(); sized = hr.width > 100 && hr.height > 100 }
|
||||
const busy = acc.filter(e => e.children.length === 0 && /Loading|Adding|Computing/i.test(e.textContent || '') && e.getBoundingClientRect().height > 0).length
|
||||
const maps = (window.__shelfMaps || []).filter(m => { try { return m.getContainer() && m.getContainer().isConnected } catch (_) { return false } }).map(m => {
|
||||
let drawn = 0, geo = 0
|
||||
try {
|
||||
const st = m.getStyle(); const srcs = st.sources || {}
|
||||
const geoIds = Object.keys(srcs).filter(k => srcs[k].type === 'geojson'); geo = geoIds.length
|
||||
const layers = (st.layers || []).filter(l => geoIds.includes(l.source)).map(l => l.id)
|
||||
if (layers.length) drawn = m.queryRenderedFeatures({ layers }).length
|
||||
} catch (_) {}
|
||||
return { loaded: !!(m.__shelfLoaded || (m.isStyleLoaded && m.isStyleLoaded())), geo, drawn }
|
||||
})
|
||||
return { own, sized, host: !!host, busy, maps }
|
||||
})()`.replace('__TAG__', tag)
|
||||
|
||||
const b = await chromium.launch({ headless: true, args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] })
|
||||
|
||||
async function measure(url, s, poisonBundle) {
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
|
||||
await p.addInitScript(RECORD_MAPS)
|
||||
const seed = SEED[s.bundle]
|
||||
if (seed) await p.addInitScript(({ keyPrefix, value }) => {
|
||||
const get = Storage.prototype.getItem
|
||||
Storage.prototype.getItem = function (k) { const v = get.call(this, k); return (v == null && String(k).startsWith(keyPrefix)) ? value : v }
|
||||
}, seed)
|
||||
if (poisonBundle) await p.route(new RegExp(`/${poisonBundle}/${poisonBundle}\\.js(\\?|$)`), r => r.fulfill({ status: 200, contentType: 'text/javascript', body: 'throw new Error("shelf self-test: deliberately broken bundle")' }))
|
||||
const errs = [], fails = []
|
||||
p.on('pageerror', e => errs.push(e.message.slice(0, 140)))
|
||||
p.on('response', r => { if (r.status() >= 400 && !/events\.mapbox\.com|favicon/.test(r.url())) fails.push(r.status() + ' ' + r.url().replace(/\?.*/, '').slice(-80)) })
|
||||
await p.goto(url)
|
||||
let st = null
|
||||
const t0 = Date.now()
|
||||
while (Date.now() - t0 < WAIT_MS) {
|
||||
await p.waitForTimeout(2500)
|
||||
st = await p.evaluate(PROBE(s.tag))
|
||||
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0)
|
||||
if (st.own > 8 && st.sized && st.busy === 0 && mapsReady && Date.now() - t0 > 6000) break
|
||||
}
|
||||
await p.close()
|
||||
const why = []
|
||||
if (!st || !st.host) why.push(`<${s.tag}> never appeared`)
|
||||
else if (st.own <= 8 || !st.sized) why.push(`<${s.tag}> mounted empty (${st.own} node(s)${st.sized ? '' : ', not sized'})`)
|
||||
if (st && st.busy) why.push(`${st.busy} loading label(s) left`)
|
||||
if (st) st.maps.forEach((m, i) => { if (!m.loaded) why.push(`map ${i + 1}: 'load' never fired`); else if (!m.drawn) why.push(`map ${i + 1}: no own data drawn (${m.geo} geojson source(s))`) })
|
||||
if (errs.length) why.push(`page error: ${errs[0]}`)
|
||||
if (fails.length) why.push(`failed request: ${fails[0]}`)
|
||||
return { pass: !why.length, why, maps: st ? st.maps.length : 0 }
|
||||
}
|
||||
|
||||
const leaf = (s, eng) => `${BASE}/${s.bundle}/${s.profile}/index.html?provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
|
||||
const gallery = (s, eng) => `${BASE}/doxa-maps-spa/doxa-maps-spa/index.html?tab=${s.bundle}&provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
|
||||
|
||||
let exit = 0
|
||||
try {
|
||||
if (SELF_TEST) {
|
||||
const s = shelf[0]
|
||||
const a = await measure(leaf(s, 'maplibre'), s, s.bundle), d = await measure(gallery(s, 'maplibre'), s, s.bundle)
|
||||
if (a.pass || d.pass) { console.error(`SELF-TEST FAILED: a deliberately broken ${s.bundle} still passed (${a.pass ? 'leaf' : ''} ${d.pass ? 'gallery' : ''}). This gate cannot see a broken bundle.`); exit = 3 }
|
||||
else console.log(`self-test: a deliberately broken ${s.bundle} fails as it must (leaf: ${a.why[0]}; gallery: ${d.why[0]})`)
|
||||
}
|
||||
if (exit !== 3) {
|
||||
for (const [k, v] of Object.entries(SEED)) if (shelf.some(s => s.bundle === k)) console.log(`SEEDED ${k}: ${v.why}`)
|
||||
console.log(`EXPERIMENTAL SHELF — ${shelf.length} bundle(s): ${shelf.map(s => s.bundle).join(', ')} · engines: ${ENGINES.join(', ')}`)
|
||||
for (const s of shelf) for (const eng of ENGINES) for (const [surf, url] of [['A leaf', leaf(s, eng)], ['D gallery', gallery(s, eng)]]) {
|
||||
const r = await measure(url, s)
|
||||
if (!r.pass) exit = 1
|
||||
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${r.maps ? r.maps + ' map(s)' : 'no map'}${r.pass ? '' : ' — ' + r.why.join('; ')}`)
|
||||
}
|
||||
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin loads on its own page and in the Gallery, under ${ENGINES.join(' and ')}`)
|
||||
}
|
||||
} finally { await b.close(); reap() }
|
||||
process.exit(exit)
|
||||
114
internal/check-gospel-english-leaks.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* check-gospel-english-leaks.mjs — a translated key must not render in English.
|
||||
*
|
||||
* THE METHOD, from rega by way of dx-shepherd. A string is a LEAK when the catalogue has a
|
||||
* DIFFERENT value for the locale and the page still shows the English one. That is a render
|
||||
* or wiring fault, and it is a different thing from an untranslated string, which is a
|
||||
* catalogue gap. Only the first is this check's business.
|
||||
*
|
||||
* WHAT IT DOES NOT COUNT, so the number means something:
|
||||
* - third-party strings, e.g. the Mapbox and geoBoundaries attributions, which are licence
|
||||
* obligations and must never be translated;
|
||||
* - DATA, the people-group and place names that come from the publishers;
|
||||
* - PLACEHOLDER-ONLY strings;
|
||||
* - the explanatory PROSE this bundle deliberately keeps in English, which is declared by
|
||||
* being absent from the catalogue entirely — if there is no translated value, there is
|
||||
* nothing to leak.
|
||||
*
|
||||
* SCOPE: THE LEAF, AND THAT BOUNDARY IS THE POINT. Six keys were reported leaking in the
|
||||
* gallery. Measured here, the standalone leaf is clean in ar, zh, ru and hi — and the
|
||||
* gallery's iframe src is built without a lang parameter, so the embedded leaf never learns
|
||||
* the locale and correctly falls back to English. That is an embedder defect, not a bundle
|
||||
* one, and a check that conflated them would have sent someone to translate strings that are
|
||||
* already translated.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { serveReaped } from './_serve-reaped.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const PORT = Number(process.env.PORT || 5399)
|
||||
const BASE = process.env.BASE_URL || ('http://127.0.0.1:' + PORT)
|
||||
const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',')
|
||||
const LEAVES = ['resources-map', 'resources-list', 'data-tables', 'charts']
|
||||
|
||||
let srv = null
|
||||
if (!process.env.BASE_URL) srv = serveReaped(resolve(REPO, 'public'), PORT)
|
||||
|
||||
const cat = (lc) => JSON.parse(readFileSync(resolve(REPO, 'library/i18n/locales/' + lc + '/gospel.json'), 'utf8'))
|
||||
const flat = (o, p = []) => Object.entries(o).flatMap(([k, v]) =>
|
||||
k.startsWith('_') ? [] : (v && typeof v === 'object' ? flat(v, [...p, k]) : [[[...p, k].join('.'), String(v)]]))
|
||||
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const en = Object.fromEntries(flat(cat('en')))
|
||||
const leaks = []
|
||||
let compared = 0
|
||||
|
||||
for (const leaf of LEAVES) {
|
||||
for (const lc of LOCALES) {
|
||||
const loc = Object.fromEntries(flat(cat(lc)))
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
await page.goto(BASE + '/doxa-maps/gospel-resources/' + leaf + '/index.html?lang=' + lc,
|
||||
{ waitUntil: 'networkidle', timeout: 90000 })
|
||||
await page.waitForTimeout(8000)
|
||||
/* ═══ READ ONLY THE CHROME, NOT THE DECLARED-ENGLISH PROSE ═══════════════════════
|
||||
* The first version read the whole shadow root and reported 113 leaks, nearly all
|
||||
* false: "JESUS Film" and "Scripture" appear inside the explanatory paragraphs this
|
||||
* bundle deliberately keeps in English, and three-letter catalogue entries like "for"
|
||||
* match inside ordinary English sentences. Both are noise, and a gate that cries 113
|
||||
* when 0 are real is worse than none — someone would have gone and "fixed" strings
|
||||
* that were already correct.
|
||||
* Every English prose block in this bundle declares itself with dir="ltr". So the
|
||||
* chrome is everything OUTSIDE those subtrees, which is exactly the population a
|
||||
* translated key is supposed to reach. */
|
||||
const text = await page.evaluate(() => {
|
||||
const sr = document.querySelector('gospel-resources-map')?.shadowRoot
|
||||
if (!sr) return ''
|
||||
/* A ShadowRoot is not clonable, so walk it and skip declared-English subtrees. */
|
||||
const parts = []
|
||||
const walk = (node) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3) { parts.push(child.textContent); continue }
|
||||
if (child.nodeType !== 1) continue
|
||||
if (child.getAttribute && child.getAttribute('dir') === 'ltr') continue
|
||||
if (child.tagName === 'STYLE' || child.tagName === 'SCRIPT') continue
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(sr)
|
||||
return parts.join(' ').replace(/\s+/g, ' ')
|
||||
})
|
||||
await page.close()
|
||||
if (!text) { leaks.push(leaf + ' ' + lc + ': rendered nothing at all'); continue }
|
||||
for (const [key, e] of Object.entries(en)) {
|
||||
const t = loc[key]
|
||||
/* Short entries match inside unrelated English words; six characters is the floor
|
||||
at which a match is about the key rather than about the alphabet. */
|
||||
if (!t || t === e || e.length < 6) continue
|
||||
compared++
|
||||
if (text.includes(e) && !text.includes(t)) leaks.push(leaf + ' ' + lc + ' ' + key + ': shows English "' + e.slice(0, 40) + '"')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' compared ' + compared + ' key/locale pair(s) across ' + LEAVES.length + ' leaves and ' + LOCALES.length + ' locales')
|
||||
if (leaks.length) {
|
||||
/* exitCode, NOT process.exit(). process.exit() terminates before pending stdout/stderr
|
||||
writes flush, and this gate proved it on itself: its first real run returned 1 with
|
||||
every explanatory line missing, so it failed without saying why. A gate that cannot
|
||||
explain its own failure is barely better than no gate. */
|
||||
console.error('GOSPEL-ENGLISH-LEAKS: FAIL — ' + leaks.length + ' translated key(s) rendering in English:')
|
||||
for (const l of leaks.slice(0, 12)) console.error(' ' + l)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('GOSPEL-ENGLISH-LEAKS: PASS — every key with a translation renders in it.')
|
||||
}
|
||||
} finally {
|
||||
await browser?.close()
|
||||
srv?.stop()
|
||||
}
|
||||
151
internal/check-host-locale-contract.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-host-locale-contract — a HOST that passes profile-config.locale gets that language.
|
||||
*
|
||||
* THE CONTRACT. `profile-config.locale` is how a host page (the Nuxt site, a WordPress embed) tells
|
||||
* an element which language to speak — ProfileLoader.vue reads it and drives the element's i18n. It
|
||||
* does that through `inject('appI18n', null)`: a bundle whose index.js never `app.provide('appI18n')`
|
||||
* hands ProfileLoader a null, the watchEffect does nothing, and the host's locale is SILENTLY
|
||||
* IGNORED — no error, just English. Found in source 2026-09-25 in six bundles; this gate is what keeps
|
||||
* the durable fix (createAppI18n's install provides appI18n itself) from quietly regressing.
|
||||
*
|
||||
* HOW, per leaf of every profile in the manifest (public/doxa-maps/<bundle>/<profile>/index.html):
|
||||
* HOST the leaf page with ONE change — "locale":"ar" added to its BASE_CONFIG (the object it passes
|
||||
* as profile-config). No ?lang=, and the page keeps its baked <html lang="en">, so the ONLY
|
||||
* route to Arabic is the host contract under test.
|
||||
* REF the same leaf with ?lang=ar (read by detectLocale when the instance is created — a different
|
||||
* path that does not depend on appI18n being provided). It answers "does this page HAVE
|
||||
* Arabic chrome to show?", so a page with no translatable text is reported n/a, not failed.
|
||||
* Arabic-script characters (U+0600–U+06FF) are counted over every text node and the placeholder /
|
||||
* aria-label / title attributes, through every shadow root, once the count stops changing.
|
||||
* VERDICT per leaf: HOST ≥ half of REF → ok · otherwise FAIL · REF < MIN → n/a (nothing to judge),
|
||||
* unless HOST ≥ MIN: then the contract holds and it is ?lang= the page ignores (ok, noted host-only).
|
||||
* SETTLE: network idle (capped at 20 s), then the count must hold for 3 s. Measured 2026-09-25: a
|
||||
* shorter settle read the control at 76 in one run and 2054 in the next.
|
||||
* TODAY (2026-09-25, before upg100's createAppI18n fix): 18 leaves FAIL — every composite among them.
|
||||
* POSITIVE CONTROL: regions-dashboard/regions-list (provides appI18n) must be ok, or the whole run is
|
||||
* NOT MEASURED — a harness that cannot see Arabic there cannot say anything about the rest.
|
||||
*
|
||||
* USAGE node internal/check-host-locale-contract.mjs [--dir <tree root>] [--only bundle/profile,…]
|
||||
* Serves THIS checkout's public/ on an OS-assigned port, or --dir (another commit's tree, for the
|
||||
* before/after). Run through internal/browser-slot.sh.
|
||||
* EXIT 0 every judged leaf enters the host's locale · 1 one does not · 75 not measured.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
|
||||
const ONLY = arg('--only', '') ? new Set(arg('--only', '').split(',')) : null
|
||||
const CONTROL = 'regions-dashboard/regions-list'
|
||||
const MIN = 20 // Arabic characters a page must show under ?lang=ar before it can be judged
|
||||
const CONCURRENCY = 3
|
||||
|
||||
const manifestPath = join(DIR, 'doxa-maps', 'manifest.json')
|
||||
if (!existsSync(manifestPath)) { console.log(`HOST-LOCALE: NOT MEASURED — no ${manifestPath}`); process.exit(75) }
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const leaves = []
|
||||
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
|
||||
const id = `${b.bundle}/${p.profile}`
|
||||
if (ONLY && !ONLY.has(id) && id !== CONTROL) continue
|
||||
if (existsSync(join(DIR, 'doxa-maps', id, 'index.html'))) leaves.push(id)
|
||||
}
|
||||
if (!leaves.includes(CONTROL)) { console.log(`HOST-LOCALE: NOT MEASURED — the control ${CONTROL} is not in this tree`); process.exit(75) }
|
||||
|
||||
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
|
||||
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
|
||||
let up = false
|
||||
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/doxa-maps/manifest.json`).then((r) => r.ok).catch(() => false) }
|
||||
if (!up) { stop(); console.log(`HOST-LOCALE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
|
||||
|
||||
const COUNT = `(() => {
|
||||
let n = 0; const AR = /[\\u0600-\\u06FF]/g
|
||||
const add = (s) => { if (s) { const m = String(s).match(AR); if (m) n += m.length } }
|
||||
const walk = (root) => {
|
||||
const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT)
|
||||
for (let node = tw.currentNode; node; node = tw.nextNode()) {
|
||||
if (node.nodeType === 3) { add(node.nodeValue); continue }
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') continue
|
||||
for (const a of ['placeholder', 'aria-label', 'title']) add(node.getAttribute && node.getAttribute(a))
|
||||
if (node.shadowRoot) walk(node.shadowRoot)
|
||||
}
|
||||
}
|
||||
walk(document); return n
|
||||
})()`
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
async function measure(id, mode) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const url = `http://127.0.0.1:${port}/doxa-maps/${id}/index.html${mode === 'ref' ? '?lang=ar' : ''}`
|
||||
let injected = mode === 'ref'
|
||||
if (mode === 'host') {
|
||||
await page.route(url, async (route) => {
|
||||
const res = await route.fetch()
|
||||
const html = await res.text()
|
||||
const out = html.replace(/var BASE_CONFIG = \{/, 'var BASE_CONFIG = {"locale":"ar",')
|
||||
injected = out !== html
|
||||
await route.fulfill({ response: res, body: out })
|
||||
})
|
||||
}
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||||
// SETTLE. A first version stopped after three equal reads 500 ms apart and read the control at 76
|
||||
// Arabic characters in one run and 2054 in another: regions-list's feed takes over a second, and
|
||||
// the page sat still (in English placeholders) until it arrived. So: wait for the network to go
|
||||
// quiet (capped — a map streams tiles for ever), then require the count to hold for 3 s.
|
||||
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {})
|
||||
let last = -1, same = 0, n = 0
|
||||
for (let i = 0; i < 60 && same < 6; i++) {
|
||||
await page.waitForTimeout(500)
|
||||
n = await page.evaluate(COUNT).catch(() => 0)
|
||||
same = n === last ? same + 1 : 0; last = n
|
||||
}
|
||||
return { n, injected }
|
||||
} catch (e) { return { n: null, injected, error: String(e.message || e).split('\n')[0] } }
|
||||
finally { await ctx.close() }
|
||||
}
|
||||
|
||||
const rows = []
|
||||
async function judge(id) {
|
||||
const host = await measure(id, 'host'), ref = await measure(id, 'ref')
|
||||
let verdict, why = host.error || ref.error || (!host.injected ? 'no BASE_CONFIG in the page' : '')
|
||||
if (!host.injected) verdict = 'unmeasured' // the page has no BASE_CONFIG to set
|
||||
else if (host.n === null || ref.n === null) verdict = 'unmeasured'
|
||||
// ?lang=ar showed no Arabic but the host path did: the page is translatable and the CONTRACT holds;
|
||||
// it is ?lang= that page ignores — a different defect, reported, not failed here.
|
||||
else if (ref.n < MIN && host.n >= MIN) { verdict = 'ok'; why = 'host-only: this page ignores ?lang=' }
|
||||
else if (ref.n < MIN) verdict = 'n/a'
|
||||
else verdict = host.n >= ref.n / 2 ? 'ok' : 'FAIL'
|
||||
rows.push({ id, host: host.n, ref: ref.n, verdict, why })
|
||||
}
|
||||
// The control runs ALONE first, so it is never starved by three maps loading beside it.
|
||||
await judge(CONTROL)
|
||||
const queue = leaves.filter((l) => l !== CONTROL)
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (queue.length) await judge(queue.shift()) }))
|
||||
await browser.close(); stop()
|
||||
|
||||
rows.sort((a, b) => (a.id === CONTROL ? -1 : b.id === CONTROL ? 1 : a.id.localeCompare(b.id)))
|
||||
console.log(`HOST-LOCALE: profile-config.locale="ar" (HOST) vs ?lang=ar (REF), Arabic-script characters drawn, ${DIR}`)
|
||||
for (const r of rows) console.log(` ${r.verdict.padEnd(10)} ${r.id.padEnd(48)} host ${String(r.host ?? '—').padStart(5)} ref ${String(r.ref ?? '—').padStart(5)}${r.id === CONTROL ? ' ← positive control' : ''}${r.why ? ' ' + r.why : ''}`)
|
||||
|
||||
const control = rows.find((r) => r.id === CONTROL)
|
||||
if (!control || control.verdict !== 'ok') {
|
||||
console.log(`HOST-LOCALE: NOT MEASURED — the positive control ${CONTROL} did not enter Arabic (host ${control && control.host}, ref ${control && control.ref}); this harness cannot judge the rest.`)
|
||||
process.exit(75)
|
||||
}
|
||||
const fails = rows.filter((r) => r.verdict === 'FAIL'), un = rows.filter((r) => r.verdict === 'unmeasured'), na = rows.filter((r) => r.verdict === 'n/a')
|
||||
if (fails.length) {
|
||||
console.log(`HOST-LOCALE: FAIL — ${fails.length} leaf/leaves ignore the host's profile-config.locale although they can speak Arabic: ${fails.map((r) => r.id).join(', ')}`)
|
||||
console.log(' The usual cause: the bundle never provides appI18n, so ProfileLoader\'s inject(\'appI18n\') is null.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (un.length) { console.log(`HOST-LOCALE: NOT MEASURED — ${un.length} leaf/leaves could not be read: ${un.map((r) => r.id).join(', ')}`); process.exit(75) }
|
||||
console.log(`HOST-LOCALE: PASS — ${rows.length - na.length} leaves enter the host's locale; ${na.length} have no Arabic chrome to judge (n/a).`)
|
||||
168
internal/check-poster-wall-localised.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-poster-wall-localised — the SHARED poster wall's own chrome must be translated
|
||||
* on every leaf that mounts it, in every locale.
|
||||
*
|
||||
* ★ WHY THIS CANNOT BE CHECKED IN ENGLISH, which is the whole reason the gap survived.
|
||||
* PosterWall guards useI18n() (it THROWS on a host that never installed vue-i18n, which blanked
|
||||
* people-groups-grid once via library FilterDropdown). The fallback HUMANIZES the key:
|
||||
* buttons.prayerList -> "Prayer List" buttons.printOptions -> "Print Options"
|
||||
* That is deliberate and good — a bare host shows readable English rather than a raw dotted key
|
||||
* or a blank button. But it means "the button says Prayer List" is TRUE both when i18n worked and
|
||||
* when it threw. An en-only check passes forever while every other locale leaks English.
|
||||
*
|
||||
* So this gate runs in NON-ENGLISH locales only. There, the translated string differs from the
|
||||
* humanized key, so seeing the humanized key IS the defect, with no ambiguity.
|
||||
*
|
||||
* ⚠ IT CHECKS THE RENDERED PAGE, NOT THE SOURCE. A static scan can see `useI18n()` wrapped in a
|
||||
* try/catch and conclude either "guarded, fine" or "might throw, bad" — neither is a measurement.
|
||||
* Whether the catch FIRES depends on what the host installed at the mount point, which only
|
||||
* exists at runtime. A scoreboard entry for this was open with nothing able to confirm or refute
|
||||
* it; that is what this closes.
|
||||
*
|
||||
* Usage: node internal/check-poster-wall-localised.mjs [baseUrl]
|
||||
* SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { servesThisTree } from './_serves-this-tree.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
|
||||
|
||||
// EVERY leaf that mounts the shared wall. key-cities and regions-dashboard are here because they
|
||||
// consume PosterWall too — the original report named only three, and a gate that copies a
|
||||
// report's sampling inherits its blind spot.
|
||||
const LEAVES = [
|
||||
'my-upg-100-list/upg-100-mfe',
|
||||
'my-upg-100-list/poster-wall',
|
||||
'country-posters/country-poster-wall',
|
||||
'key-cities/city-poster-wall',
|
||||
'regions-dashboard/regions-list',
|
||||
]
|
||||
// ro is the widest-rendering Latin locale, ar is RTL, zh is double-width CJK: three different
|
||||
// ways a translation can be wrong, and all three differ from the humanized English key.
|
||||
const LANGS = (process.env.PW_LANG || 'ro,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
// The humanizeKey outputs for every key the wall renders. Seeing ONE of these in a non-English
|
||||
// locale means the fallback fired. Derived from the same transform the component uses, so the
|
||||
// two cannot drift: last path segment, camelCase split, first letter upper.
|
||||
const KEYS = ['buttons.prayerList', 'buttons.posterMap', 'buttons.cardDeck', 'buttons.printOptions',
|
||||
'buttons.postersPerRow', 'buttons.showOnMap', 'buttons.clearSelection', 'buttons.cardStockAria']
|
||||
const humanizeKey = (k) => String(k).split('.').pop()
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
||||
const FALLBACKS = new Set(KEYS.map(humanizeKey))
|
||||
|
||||
let reachable = false
|
||||
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
|
||||
if (!reachable) {
|
||||
console.log(`POSTER-WALL-LOCALISED: SKIP — nothing served at ${BASE}.`)
|
||||
console.log(' Serve the built tree: bun run serve (or set BASE_URL).')
|
||||
process.exit(0)
|
||||
}
|
||||
const treeCheck = await servesThisTree(BASE, ROOT)
|
||||
if (!treeCheck.ok) {
|
||||
console.error('POSTER-WALL-LOCALISED: NOT MEASURED — the server is not serving this checkout.')
|
||||
console.error(` ${treeCheck.why}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const WALK = `(root) => { const o=[]; const rec=(r)=>{ for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const rows = []
|
||||
const fails = []
|
||||
|
||||
for (const leaf of LEAVES) {
|
||||
for (const lang of LANGS) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
let r = null
|
||||
try {
|
||||
const res = await page.goto(`${BASE}/doxa-maps/${leaf}/index.html?lang=${lang}`,
|
||||
{ waitUntil: 'load', timeout: 45000 })
|
||||
if (!res || !res.ok()) { rows.push({ leaf, lang, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
|
||||
await page.waitForTimeout(13000)
|
||||
r = await page.evaluate(`(() => {
|
||||
const all = (${WALK})(document)
|
||||
const cls = (e, n) => new RegExp('\\\\b' + n + '\\\\b').test(e.className || '')
|
||||
const texts = (n) => [...new Set(all.filter((e) => cls(e, n)).map((e) => e.textContent.trim()).filter(Boolean))]
|
||||
const legend = all.find((e) => e.tagName === 'LEGEND' && e.parentElement && /\\bpfs\\b/.test(e.parentElement.className || ''))
|
||||
const perrow = all.find((e) => cls(e, 'perrow__label'))
|
||||
return {
|
||||
cards: all.filter((e) => cls(e, 'card')).length,
|
||||
chrome: [
|
||||
...texts('pbtn'),
|
||||
...(legend ? [legend.textContent.trim()] : []),
|
||||
...(perrow ? [perrow.textContent.trim()] : []),
|
||||
...texts('pstock'),
|
||||
],
|
||||
showOnMap: texts('tape-h'),
|
||||
}
|
||||
})()`)
|
||||
} catch (e) {
|
||||
rows.push({ leaf, lang, skipped: String(e.message || e).slice(0, 60) })
|
||||
await page.close(); continue
|
||||
}
|
||||
await page.close()
|
||||
|
||||
// ⚠ TWO WAYS THIS GATE CAN PASS WITHOUT CHECKING ANYTHING, and both were REAL on the first
|
||||
// run — a gate that cannot tell "clean" from "saw nothing" is the same failure it exists to
|
||||
// catch. A wall with no cards renders no chrome; and regions-list rendered 7 cards with
|
||||
// chrome 0, because that profile mounts the wall WITHOUT the print row, so there was no
|
||||
// translatable string on the page at all and "all translated" was vacuously true.
|
||||
if (!r.cards) { rows.push({ leaf, lang, skipped: 'no poster cards rendered' }); continue }
|
||||
if (!r.chrome.length && !r.showOnMap.length) {
|
||||
rows.push({ leaf, lang, skipped: `${r.cards} card(s) but ZERO wall chrome — nothing to check` })
|
||||
continue
|
||||
}
|
||||
|
||||
// "Show on map" is a COMPOSED string ("116 UPG·Show on map"), so compare by containment.
|
||||
const leaked = [
|
||||
...r.chrome.filter((t) => FALLBACKS.has(t)),
|
||||
...r.showOnMap.filter((t) => [...FALLBACKS].some((f) => t.includes(f))),
|
||||
]
|
||||
rows.push({ leaf, lang, cards: r.cards, chrome: r.chrome.length, leaked, errs: errs.length })
|
||||
if (leaked.length) {
|
||||
fails.push(`${leaf} @ ${lang}: ${leaked.length} untranslated string(s) — ${[...new Set(leaked)].join(' · ')}`)
|
||||
}
|
||||
if (errs.length) fails.push(`${leaf} @ ${lang}: ${errs.length} page error(s) — ${errs[0]}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
console.log('POSTER-WALL-LOCALISED')
|
||||
console.log(` base : ${BASE}`)
|
||||
for (const row of rows) {
|
||||
if (row.skipped) { console.log(` ${row.leaf} @ ${row.lang} SKIP — ${row.skipped}`); continue }
|
||||
console.log(` ${row.leaf} @ ${row.lang} cards ${row.cards} · chrome ${row.chrome} string(s) · `
|
||||
+ `${row.leaked.length ? `LEAKED ${row.leaked.length}` : 'all translated'} · ${row.errs} error(s)`)
|
||||
}
|
||||
const measured = rows.filter((x) => !x.skipped).length
|
||||
const skipped = rows.filter((x) => x.skipped)
|
||||
if (!measured) {
|
||||
console.error('\nPOSTER-WALL-LOCALISED: NOT MEASURED — every leaf skipped, so nothing was checked.')
|
||||
process.exit(2)
|
||||
}
|
||||
// ★ NO SILENT CAPS. A leaf that never got measured is not a leaf that passed, and a summary that
|
||||
// counts only the green ones reads as full coverage. Name what was dropped and why, every run —
|
||||
// key-cities ships NO leaf pages (bundle JS only), so it can never be reached by URL, and that
|
||||
// is a coverage fact a reader of this output needs rather than a 404 buried mid-list.
|
||||
if (skipped.length) {
|
||||
const byLeaf = new Map()
|
||||
for (const s of skipped) byLeaf.set(s.leaf, s.skipped)
|
||||
console.log(`\n NOT COVERED (${skipped.length} combination(s)):`)
|
||||
for (const [leaf, why] of byLeaf) console.log(` ${leaf} — ${why}`)
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\nPOSTER-WALL-LOCALISED: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(` - ${f}`)
|
||||
console.error('\n The shared wall humanizes its i18n keys when useI18n() throws, so the fallback')
|
||||
console.error(' reads as fluent English ("Prayer List"). That is why this can only be seen in a')
|
||||
console.error(' non-English locale, and why an en-only check would pass while every other')
|
||||
console.error(' locale leaked.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPOSTER-WALL-LOCALISED: PASS — ${measured} leaf x locale combination(s), `
|
||||
+ `${LANGS.join('/')}. The shared wall's own chrome is translated everywhere it mounts.`)
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
* each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a
|
||||
* human can READ it: streets, building footprints, the zone outline on top.
|
||||
* 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page.
|
||||
* 5. FULL SCREEN — from a 520px embed, the whole planner takes the viewport.
|
||||
* 6. PRINT RESOLUTION — the printed map measures >= 300 DPI at the sheet's printable width.
|
||||
*
|
||||
* Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared
|
||||
* browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written.
|
||||
|
|
@ -69,6 +71,17 @@ try {
|
|||
console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`)
|
||||
await p.close()
|
||||
}
|
||||
// 1b. FULL SCREEN from inside a small embed: the whole planner (toolbar + map) takes the viewport.
|
||||
{
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
|
||||
const fsHost = createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><body style="margin:0"><h1 style="height:80px;margin:0">host</h1><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="prayer-walk-planner" style="height:520px"></doxa-maps-spa></body>`) })
|
||||
const FP = await new Promise(ok => fsHost.listen(0, 'localhost', () => ok(fsHost.address().port)))
|
||||
await p.goto(`http://localhost:${FP}/`); await p.waitForTimeout(9000)
|
||||
const fs = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const btn = el.shadowRoot.querySelector('.pwp-map-controls button'); if (!btn) return null; const before = Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height); btn.click(); await new Promise(r => setTimeout(r, 2500)); const r = el.getBoundingClientRect(); return { before, after: Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height), host: [Math.round(r.width), Math.round(r.height)] } })()`)
|
||||
const fok = !!fs && fs.host[0] >= 1270 && fs.host[1] >= 790 && fs.after > fs.before
|
||||
console.log(`${fok ? 'PASS' : 'FAIL'} full screen from a 520px embed: ${JSON.stringify(fs)}`); results.push({ check: 'full screen', ok: fok })
|
||||
await p.close(); fsHost.close()
|
||||
}
|
||||
// 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es)
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = []
|
||||
p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
|
|
@ -86,6 +99,24 @@ try {
|
|||
console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st })
|
||||
} else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) }
|
||||
console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length })
|
||||
|
||||
// 5. PRINT RESOLUTION, MEASURED: the real printed sheet laid out as PRINT at the printable width
|
||||
// (8.5in - 2 x 12mm @page margins = 725 CSS px; emulateMedia alone does NOT apply @page margins,
|
||||
// which is how a first measurement read the frame as 8.48in wide). DPI = natural px / printed in.
|
||||
// The Driver: "a print that stays sharp when zoomed" — at >= 300 DPI a zoomed PDF shows real
|
||||
// street names and house outlines, not an upscaled screen grab.
|
||||
await p.evaluate(() => { new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes) if (n.tagName === 'IFRAME') { try { n.contentWindow.print = () => {} } catch (_) {} window.__pwpFrame = n } }).observe(document.body, { childList: true }) })
|
||||
await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const z = el.listPolygons().find(x => x.title === 'Balat'); el.selectPolygon(z.id, true); await el.print() })()`)
|
||||
await p.waitForTimeout(3000)
|
||||
const sheet = await p.evaluate(() => window.__pwpFrame ? window.__pwpFrame.contentDocument.documentElement.outerHTML : '')
|
||||
if (sheet) {
|
||||
const q = await b.newPage(); await q.emulateMedia({ media: 'print' }); await q.setViewportSize({ width: 725, height: 965 })
|
||||
await q.setContent('<!DOCTYPE html>' + sheet.replace(/<script>[\s\S]*?<\/script>/g, ''), { waitUntil: 'load' }); await q.waitForTimeout(1200)
|
||||
const dpi = await q.evaluate(() => { const i = document.querySelector('.snapshot'); if (!i) return null; const r = i.getBoundingClientRect(); return { naturalPx: [i.naturalWidth, i.naturalHeight], printedInches: [+(r.width / 96).toFixed(2), +(r.height / 96).toFixed(2)], dpi: [Math.round(i.naturalWidth / (r.width / 96)), Math.round(i.naturalHeight / (r.height / 96))] } })
|
||||
const dok = !!dpi && dpi.dpi[0] >= 300 && dpi.dpi[1] >= 300
|
||||
console.log(`${dok ? 'PASS' : 'FAIL'} print resolution: ${JSON.stringify(dpi)}`); results.push({ check: 'print dpi', ok: dok })
|
||||
await q.close()
|
||||
} else { console.log('FAIL print resolution: the print frame never appeared'); results.push({ check: 'print dpi', ok: false }) }
|
||||
await p.close()
|
||||
} finally { await b.close(); host.close(); srv.kill() }
|
||||
const bad = results.filter(r => !r.ok).length
|
||||
|
|
|
|||
|
|
@ -32,8 +32,14 @@
|
|||
* actual side by side.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __treeGuard(BASE, __ROOT, 'SPA-TAB-ORDER')
|
||||
|
||||
// The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive,
|
||||
// whitespace-collapsed) so a row may carry its subtitle.
|
||||
|
|
|
|||
76
internal/check-staging-forwards-lang.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-staging-forwards-lang — a staging page opened at ?lang=ar embeds its leaves in Arabic.
|
||||
*
|
||||
* THE BUG (found by gospel 2026-09-25). The tree's staging page (public/doxa-maps/index.html, built
|
||||
* from internal/staging-forge.js) built each embedded leaf's URL as withProvider(withTk(href)) — the
|
||||
* token and the engine rode along, the language did not. The leaf pages bake <html lang="en">, so a
|
||||
* maintainer or reviewer reading the tree at ?lang=ar saw every map in English, and the leaf was
|
||||
* right to do so: nothing told it otherwise.
|
||||
*
|
||||
* WHAT IS ASSERTED, on the served tree:
|
||||
* 1. /doxa-maps/index.html?lang=ar, gospel-resources/resources-map selected: the stage iframe's src
|
||||
* AND the "open ↗" link both carry lang=ar;
|
||||
* 2. inside that iframe the Gospel map's mode toggle reads gospel.mode.gap in Arabic ("الفجوة",
|
||||
* from library/i18n/locales/ar/gospel.json) — the language actually arrived, not just the param.
|
||||
*
|
||||
* USAGE node internal/check-staging-forwards-lang.mjs [--dir <tree root>] (through browser-slot.sh)
|
||||
* EXIT 0 forwarded and rendered · 1 not · 75 not measured.
|
||||
* NEGATIVE-TESTED 2026-09-25: on the tree built before withLang() existed it FAILS at step 1.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
|
||||
const KEY = 'gospel-resources/resources-map'
|
||||
const GAP_AR = JSON.parse(readFileSync(resolve(ROOT, 'library/i18n/locales/ar/gospel.json'), 'utf8')).mode.gap
|
||||
|
||||
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
|
||||
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
|
||||
let up = false
|
||||
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/doxa-maps/index.html`).then((r) => r.ok).catch(() => false) }
|
||||
if (!up) { stop(); console.log(`STAGING-LANG: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
const done = async (code, msg) => { console.log(msg); await browser.close(); stop(); process.exit(code) }
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
||||
await page.goto(`http://127.0.0.1:${port}/doxa-maps/index.html?lang=ar`, { waitUntil: 'load' })
|
||||
const node = page.locator(`.node[data-key="${KEY}"]`)
|
||||
if (!(await node.count())) await done(75, `STAGING-LANG: NOT MEASURED — no ${KEY} node on the staging page`)
|
||||
await node.first().click()
|
||||
await page.waitForSelector('#stage iframe', { timeout: 15000 }).catch(() => {})
|
||||
const src = await page.locator('#stage iframe').first().getAttribute('src').catch(() => null)
|
||||
const pop = await page.locator('#stage a.popout').first().getAttribute('href').catch(() => null)
|
||||
if (!src) await done(75, 'STAGING-LANG: NOT MEASURED — the stage never rendered an iframe')
|
||||
const carries = (u) => new URL(u, 'http://x/').searchParams.get('lang') === 'ar'
|
||||
console.log(` iframe src ${carries(src) ? 'carries lang=ar' : 'NO lang'} ${src}`)
|
||||
console.log(` open ↗ ${pop && carries(pop) ? 'carries lang=ar' : 'NO lang'} ${pop}`)
|
||||
if (!carries(src) || !pop || !carries(pop)) await done(1, 'STAGING-LANG: FAIL — the staging page does not forward ?lang= to the leaf it embeds.')
|
||||
|
||||
const frame = page.frameLocator('#stage iframe')
|
||||
let label = null
|
||||
for (let i = 0; i < 60 && label === null; i++) {
|
||||
await page.waitForTimeout(500)
|
||||
label = await frame.locator('body').evaluate(() => {
|
||||
// the toggle lives inside the element's shadow root (possibly nested) — walk every root
|
||||
const f = (r) => {
|
||||
const b = [...r.querySelectorAll('button.gm-mode')]
|
||||
if (b.length) return b.map((x) => x.textContent.trim())
|
||||
for (const e of r.querySelectorAll('*')) if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m }
|
||||
return null
|
||||
}
|
||||
return f(document)
|
||||
}).catch(() => null)
|
||||
}
|
||||
if (!label) await done(75, 'STAGING-LANG: NOT MEASURED — the Gospel map\'s mode toggle never rendered in the iframe')
|
||||
console.log(` mode toggle ${JSON.stringify(label)} (expect "${GAP_AR}")`)
|
||||
if (!label.includes(GAP_AR)) await done(1, `STAGING-LANG: FAIL — lang=ar reached the iframe but the Gospel map did not render gospel.mode.gap in Arabic.`)
|
||||
await done(0, 'STAGING-LANG: PASS — ?lang=ar is forwarded to the embedded leaf and the Gospel map renders in Arabic.')
|
||||
|
|
@ -14,8 +14,14 @@
|
|||
* Plugin gallery dev server: --url http://localhost:5174/plugin-designs/doxa-simple-map/index.html
|
||||
*/
|
||||
import { launch, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const args = process.argv.slice(2); const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const URL = argOf('--url', 'http://localhost:3001/pray'), TAG = argOf('--tag', 'doxa-map'), W = +argOf('--width', 1440), H = +argOf('--height', 900), ROWS = +argOf('--rows', 8)
|
||||
// Guard ONLY when pointed at OUR dev gallery (/plugin-designs/…). The default target is the DX-life
|
||||
// site (a different app this probe compares against on purpose), which has no source of ours to match.
|
||||
if (/\/plugin-designs\//.test(URL)) await requireDevServesThisCheckout(URL, __rs(__dn(__f2p(import.meta.url)), '..'), 'LEGEND-COLUMNS')
|
||||
const browser = await launch('firefox', { label: 'legend-columns' })
|
||||
const ctx = await browser.newContext({ viewport: { width: W, height: H }, hasTouch: true, isMobile: W < 600 }); const page = await ctx.newPage()
|
||||
await page.route(TELEMETRY, r => r.abort())
|
||||
|
|
|
|||
|
|
@ -56,12 +56,18 @@
|
|||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const TOOL = 'long-frames-probe'
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const argAll = (k) => args.flatMap((a, i) => a === k && args[i + 1] ? [args[i + 1]] : [])
|
||||
const BASE = String(argOf('--base', 'http://localhost:5174')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'LONG-FRAMES')
|
||||
const URLS = argAll('--url')
|
||||
const TAGS = argAll('--tag')
|
||||
const GREP = argOf('--grep', '')
|
||||
|
|
|
|||
|
|
@ -61,11 +61,17 @@
|
|||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { launch, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const TOOL = 'mobile-sheet-probe'
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const URL = String(argOf('--url', 'http://localhost:5174/plugin-designs/doxa-simple-map/index.html'))
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(URL, __ROOT, 'MOBILE-SHEET')
|
||||
const TAG = argOf('--tag', 'doxa-map')
|
||||
const NTH = Number(argOf('--nth', 0))
|
||||
const INSTANCE = argOf('--instance', '')
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
|
|
@ -40,6 +44,8 @@ const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1]
|
|||
// cannot reach its subject has not passed and has not failed: it has not run, and the message
|
||||
// blames the user for not starting a server they had started.
|
||||
const BASE = String(argOf('--url', process.env.DEV_URL || 'http://localhost:5173')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'PLUGIN-GALLERY-SMOKE')
|
||||
|
||||
// Paint needs a GPU this build box does not have; see the long note at the canvas assertion below.
|
||||
const EXPECT_CANVAS = process.env.EXPECT_CANVAS === '1'
|
||||
|
|
|
|||
|
|
@ -385,6 +385,10 @@ await shot('prod-prayer-phone', '/doxa-maps/doxa-simple-map/doxa-simple-map/pray
|
|||
await shot('prod-cities-internet-phone', '/doxa-maps/cities-internet/connectivity-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
|
||||
await shot('prod-gospel-resources-phone', '/doxa-maps/gospel-resources/resources-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
|
||||
|
||||
// ---- EXPERIMENTAL SHELF: the plugins the README pictures there ----
|
||||
await shot('exp-wall-of-people-groups', '/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html', { wait: 12000, minElements: 40 })
|
||||
await shot('exp-prayer-walk-planner', '/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html', { wait: 12000, minElements: 40 })
|
||||
|
||||
// ---- DEPLOY2: mobile — the UPG-100 list as the phone app ----
|
||||
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
|
||||
// it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
|
||||
const TOOL = 'share-smoke'
|
||||
|
|
@ -63,6 +67,8 @@ const GREP = argOf('--grep', '')
|
|||
const GALLERY = has('--plugin-gallery')
|
||||
? String((() => { const v = args[args.indexOf('--plugin-gallery') + 1]; return v && !v.startsWith('--') ? v : 'http://localhost:5174' })()).replace(/\/+$/, '')
|
||||
: null
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
if (GALLERY) await __devGuard(GALLERY, __ROOT, 'SHARE-ROUNDTRIP')
|
||||
|
||||
if (!URLS.length && !GALLERY) {
|
||||
console.error(`[${TOOL}] usage: --url <host page URL> [--url ...] [--tag <tag>] | --plugin-gallery [base] (see header)`)
|
||||
|
|
|
|||
|
|
@ -47,12 +47,18 @@
|
|||
* removing tree-base makes it FAIL on the empty tab list. A gate that cannot fail is worse than none.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const argOf = (flag, dflt) => {
|
||||
const i = process.argv.indexOf(flag)
|
||||
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt
|
||||
}
|
||||
const BASE = String(argOf('--url', 'http://localhost:5173')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'SMOKE-STUDIO')
|
||||
const BUDGET_MS = Number(process.env.SMOKE_BUDGET_MS || 20000)
|
||||
|
||||
// Deep walker — SHADOW DOM DOES NOT PIERCE. A flat querySelectorAll lies about this component, and a
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ function withProvider(href) {
|
|||
const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null
|
||||
return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href
|
||||
}
|
||||
// Carry the page's ?lang= (or ?locale=) into the embedded leaf the same way. The leaf pages bake
|
||||
// <html lang="en">, so without this a staging page opened at ?lang=ar embedded every leaf in
|
||||
// English — measured: 0 of 1 iframes carried it. Only forwarded when present; the leaf's own
|
||||
// detectLocale() still decides what it supports.
|
||||
function withLang(href) {
|
||||
const q = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null
|
||||
const l = q && (q.get('lang') || q.get('locale'))
|
||||
return l ? href + (href.includes('?') ? '&' : '?') + 'lang=' + encodeURIComponent(l) : href
|
||||
}
|
||||
|
||||
// ── Load the semantic tree (inline-first; fetch = dev fallback) ──────────────
|
||||
async function loadTree() {
|
||||
|
|
@ -151,10 +160,10 @@ function renderStage() {
|
|||
<div class="stage-head">
|
||||
<span class="sctag"><${esc(nd.element || nd.label)}></span>
|
||||
<span class="sckind">${esc(nd.kind)}</span>
|
||||
<a class="popout" href="${esc(withProvider(withTk(nd.href)))}" target="_blank" rel="noopener">open ↗</a>
|
||||
<a class="popout" href="${esc(withLang(withProvider(withTk(nd.href))))}" target="_blank" rel="noopener">open ↗</a>
|
||||
<button class="popout" id="copyEmbed" type="button" title="Copy an iframe snippet with this host's full URL">copy embed</button>
|
||||
</div>
|
||||
<div class="map-slot"><iframe allowfullscreen src="${esc(withProvider(withTk(nd.href)))}"></iframe></div>`
|
||||
<div class="map-slot"><iframe allowfullscreen src="${esc(withLang(withProvider(withTk(nd.href))))}"></iframe></div>`
|
||||
// COPY EMBED — the partner handoff. The snippet is built AT CLICK TIME from
|
||||
// location.href, so it always carries the FULL URL of whatever host serves
|
||||
// this tree (localhost, doxa.life/embed/doxa-maps-build/…, any CDN) — embeds
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ window.MANIFEST = {
|
|||
|
||||
const tk = new URLSearchParams(location.search).get('tk') || '';
|
||||
const withTk = u => u ? (tk ? u + (u.includes('?')?'&':'?') + 'tk=' + encodeURIComponent(tk) : u) : '';
|
||||
/* Carry ?lang= / ?locale= into the lazy leaf iframe too — leaves bake <html lang="en">. */
|
||||
const qlang = new URLSearchParams(location.search).get('lang') || new URLSearchParams(location.search).get('locale') || '';
|
||||
const withLang = u => u ? (qlang ? u + (u.includes('?')?'&':'?') + 'lang=' + encodeURIComponent(qlang) : u) : '';
|
||||
const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; };
|
||||
|
||||
/* ONE node renderer used for all three kinds — the data/presentation split. */
|
||||
|
|
@ -98,7 +101,7 @@ function node(kind, name, hint, id, frameUrl, children){
|
|||
if(frameUrl){
|
||||
const f = el('div','frame'); const ifr = el('iframe'); ifr.loading='lazy';
|
||||
f.append(ifr); d.append(f);
|
||||
d.addEventListener('toggle', () => { if(d.open && !ifr.src) ifr.src = withTk(frameUrl); });
|
||||
d.addEventListener('toggle', () => { if(d.open && !ifr.src) ifr.src = withLang(withTk(frameUrl)); });
|
||||
}
|
||||
(children||[]).forEach(c => d.append(c));
|
||||
return d;
|
||||
|
|
|
|||
|
|
@ -119,11 +119,17 @@
|
|||
import { writeFileSync, mkdirSync, readFileSync, rmSync, mkdtempSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const BASE = String(argOf('--url', 'http://localhost:5174')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'THEME-FLASH')
|
||||
const GREP = argOf('--grep', '')
|
||||
const TARGET = argOf('--target', '')
|
||||
const TAG = argOf('--tag', '')
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ const DEFAULT_LEAVES = [
|
|||
'my-upg-100-list/upg-100-list/index.html',
|
||||
'my-upg-100-list/upg-100-mfe/index.html',
|
||||
'research-dashboard/research-dashboard/index.html',
|
||||
// added 2026-09-25 (the regions map was never painted by this gate), verified PASS local and live
|
||||
'regions-dashboard/region-dashboards/index.html',
|
||||
'regions-dashboard/regions-mfe/index.html',
|
||||
]
|
||||
|
||||
const urls = process.argv.slice(2).length
|
||||
|
|
|
|||
17
package.json
|
|
@ -15,6 +15,7 @@
|
|||
"tree": "node internal/generate-tree.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"check:drop": "node internal/drop-portability-check.mjs",
|
||||
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
|
||||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"verify": "node library/scripts/research/verify-all.mjs",
|
||||
"verify:full": "node library/scripts/research/verify-all.mjs --full",
|
||||
|
|
@ -126,6 +127,7 @@
|
|||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:url-resolver": "node internal/check-url-resolver.mjs",
|
||||
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
|
||||
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
|
||||
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
|
||||
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
|
||||
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
|
||||
|
|
@ -168,6 +170,7 @@
|
|||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
|
||||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
|
||||
"check:staging-lang": "node internal/check-staging-forwards-lang.mjs",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
|
||||
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
|
||||
|
|
@ -202,7 +205,11 @@
|
|||
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
|
||||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
|
||||
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
|
||||
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
|
||||
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
|
||||
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
@ -225,6 +232,7 @@
|
|||
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
|
||||
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
|
|
@ -277,6 +285,7 @@
|
|||
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
|
||||
"check:staging-lang": "the tree's staging page (public/doxa-maps/index.html, from internal/staging-forge.js) opened at ?lang=ar forwards lang=ar to the leaf it embeds (iframe src AND the open link), and the Gospel map inside renders gospel.mode.gap in Arabic. Negative-tested: the tree built before withLang() FAILS. --dir <tree>. Through browser-slot.sh.",
|
||||
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
|
||||
|
|
@ -306,7 +315,11 @@
|
|||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test."
|
||||
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
|
||||
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
|
||||
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
|
||||
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
|
||||
"check:experimental-shelf": "Every Experimental-folder bundle, leaf + Gallery tab, mapbox + maplibre: mounts with content, 0 errors / failed requests, maps load and DRAW their own data. Self-tests on a deliberately broken bundle first. Run through internal/browser-slot.sh."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -43,8 +43,14 @@ const config = computed(() => {
|
|||
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
||||
})
|
||||
|
||||
// The same ladder as the rest of the library (i18n/index.js detectLocale): the host's profile-config
|
||||
// first, then ?lang= / ?locale= — without it the card was unreachable in any other language on the
|
||||
// built tree, whose leaf pages all bake <html lang="en"> — then the page's lang.
|
||||
const urlLang = (() => {
|
||||
try { const q = new URLSearchParams(window.location.search); return q.get('lang') || q.get('locale') || '' } catch { return '' }
|
||||
})()
|
||||
const locale = computed(() => {
|
||||
const raw = String(config.value.locale || (typeof document !== 'undefined' && document.documentElement.lang) || 'en')
|
||||
const raw = String(config.value.locale || urlLang || (typeof document !== 'undefined' && document.documentElement.lang) || 'en')
|
||||
const base = raw.split('-')[0].toLowerCase()
|
||||
return STRINGS[raw] ? raw : (STRINGS[base] ? base : 'en')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@
|
|||
* source of truth (the `polygons` ref), so a human clicking buttons and an agent
|
||||
* calling methods can freely interleave.
|
||||
*/
|
||||
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
|
||||
|
|
@ -102,6 +103,14 @@ const uiLocale = computed(() => {
|
|||
const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr'))
|
||||
const tr = (key, params) => translate(uiLocale.value, key, params)
|
||||
|
||||
// The shared controls (Share, Full screen) label themselves through the vue-i18n instance index.js
|
||||
// installs, which reads only the PAGE language. Inside the Research Gallery the language arrives in
|
||||
// profile-config instead, so keep that instance on the planner's own locale.
|
||||
const appI18n = inject('appI18n', null)
|
||||
watch(uiLocale, (l) => {
|
||||
try { if (appI18n && appI18n.global) { if (typeof appI18n.global.locale === 'object') appI18n.global.locale.value = l; else appI18n.global.locale = l } } catch (_) { /* labels stay in the page language */ }
|
||||
}, { immediate: true })
|
||||
|
||||
const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10))
|
||||
const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID
|
||||
|
||||
|
|
@ -230,6 +239,9 @@ function setupMapLayers() {
|
|||
map.value.addLayer({
|
||||
id: 'pwp-polygons-label', type: 'symbol', source: POLY_SRC,
|
||||
layout: {
|
||||
// A font OpenFreeMap's glyph server has. With no text-font, MapLibre asks for its default
|
||||
// (Open Sans / Arial Unicode MS), which OpenFreeMap 404s, and no zone title ever drew.
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-field': ['get', 'title'],
|
||||
'text-size': 12,
|
||||
'text-anchor': 'center'
|
||||
|
|
@ -612,6 +624,8 @@ useShadowStyles(
|
|||
'.pwp-body{position:relative;flex:1;min-height:0;display:flex}' +
|
||||
'.pwp-map{position:relative;flex:1;min-width:0}' +
|
||||
'.pwp-map-el{position:absolute;inset:0}' +
|
||||
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4}' +
|
||||
'.pwp[dir=rtl] .pwp-map-controls{right:auto;left:10px}' +
|
||||
'.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' +
|
||||
'.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' +
|
||||
'.pwp-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;margin-bottom:4px;' +
|
||||
|
|
@ -622,14 +636,26 @@ useShadowStyles(
|
|||
'.pwp-item-title-input{flex:1;font-size:13px;font-weight:600;padding:2px 4px;border:1px solid #92b195;border-radius:4px}' +
|
||||
'.pwp-item-del{border:none;background:transparent;color:#8a3b3b;cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px}' +
|
||||
'.pwp-item-del:hover{background:rgba(138,59,59,.12)}' +
|
||||
'@media (max-width:640px){.pwp-list{display:none}}'
|
||||
'@media (max-width:640px){.pwp-list{display:none}}' +
|
||||
// PHONE: ONE ROW, SCROLLED SIDEWAYS, instead of a toolbar that wrapped to three rows (139px of a
|
||||
// 844px screen, measured 2026-09-25) above the map a walker is trying to read. Nothing is clipped:
|
||||
// the row scrolls, labels never truncate. The two actions a walk needs, Draw and Print, lead the
|
||||
// row so they are always on screen; the list-management buttons follow.
|
||||
'@media (max-width:640px){' +
|
||||
'.pwp-toolbar{flex-wrap:nowrap;overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;scrollbar-width:none;padding:8px 10px;gap:6px}' +
|
||||
'.pwp-toolbar::-webkit-scrollbar{display:none}' +
|
||||
'.pwp-toolbar>*{flex:0 0 auto}' +
|
||||
'.pwp-btn{white-space:nowrap;padding:8px 12px}' +
|
||||
'.pwp-spacer{display:none}' +
|
||||
'.pwp-draw{order:-2}.pwp-print{order:-1}' +
|
||||
'}'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pwp" :dir="uiDir" :lang="uiLocale">
|
||||
<div class="pwp-toolbar">
|
||||
<button type="button" class="pwp-btn" :class="{ 'is-active': drawing }"
|
||||
<button type="button" class="pwp-btn pwp-draw" :class="{ 'is-active': drawing }"
|
||||
:aria-pressed="drawing" @click="onDrawClick">
|
||||
{{ drawing ? tr('drawing') : tr('draw') }}
|
||||
</button>
|
||||
|
|
@ -649,7 +675,7 @@ useShadowStyles(
|
|||
</button>
|
||||
<div class="pwp-spacer"></div>
|
||||
<span class="pwp-count">{{ tr('countSelected', { n: selectedCount, total: polygons.length }) }}</span>
|
||||
<button type="button" class="pwp-btn is-accent" :disabled="selectedCount === 0"
|
||||
<button type="button" class="pwp-btn is-accent pwp-print" :disabled="selectedCount === 0"
|
||||
@click="onPrintClick" :aria-label="tr('printAria')">
|
||||
{{ tr('printSelected') }}
|
||||
</button>
|
||||
|
|
@ -673,6 +699,11 @@ useShadowStyles(
|
|||
<div class="pwp-body">
|
||||
<div class="pwp-map">
|
||||
<div ref="mapContainer" class="pwp-map-el"></div>
|
||||
<!-- FULL SCREEN for the WHOLE planner (toolbar + map), not just the canvas: the shared
|
||||
FullscreenButton requests fullscreen on this element's custom-element host, and falls
|
||||
back to a CSS pseudo-fullscreen where the API does not exist (iOS Safari). A planner
|
||||
embedded in a partner page or the gallery can take the whole screen for drawing. -->
|
||||
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /></div>
|
||||
</div>
|
||||
<div class="pwp-list">
|
||||
<div v-if="polygons.length === 0" class="pwp-list-empty">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
* token-less via the gated host hook). Lives inside the doxa-upg-neighbors merged profile
|
||||
* as the "Flowers" view; reuses the granular per-PG coords from UPGN-MERGE.
|
||||
*/
|
||||
import { labelFont } from '../labelFont.js'
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -222,7 +223,7 @@ function paint() {
|
|||
})
|
||||
if (!m.getLayer(L_CORE_LBL)) m.addLayer({
|
||||
id: L_CORE_LBL, type: 'symbol', source: SRC_CORE, minzoom: 2.2,
|
||||
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
|
||||
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top',
|
||||
'text-allow-overlap': false, 'text-optional': true },
|
||||
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#fff', 'text-halo-width': 1.4 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* (this child's own <style> would be dropped by defineCustomElement).
|
||||
*/
|
||||
|
||||
import { labelFont } from '../labelFont.js'
|
||||
import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -197,7 +198,7 @@ function ensureLayers(m) {
|
|||
})
|
||||
if (!m.getLayer(L_GREEN_LBL)) m.addLayer({
|
||||
id: L_GREEN_LBL, type: 'symbol', source: SRC_GREEN, minzoom: 3.4,
|
||||
layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
|
||||
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 10, 'text-offset': [0, 1.1],
|
||||
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
|
||||
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
|
||||
})
|
||||
|
|
@ -212,7 +213,7 @@ function ensureLayers(m) {
|
|||
})
|
||||
if (!m.getLayer(L_RED_LBL)) m.addLayer({
|
||||
id: L_RED_LBL, type: 'symbol', source: SRC_RED, minzoom: 2.4,
|
||||
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
|
||||
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.2],
|
||||
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
|
||||
paint: { 'text-color': COLORS.RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
|
||||
})
|
||||
|
|
|
|||
17
plugin-designs/upg-neighbor/src/labelFont.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* labelFont.js — a text-font the map's own glyph server actually has.
|
||||
*
|
||||
* A symbol layer with no text-font asks for the engine default ("Open Sans Regular, Arial Unicode MS
|
||||
* Regular"), and the bold labels here named Mapbox's "DIN Offc Pro Bold". Mapbox serves both.
|
||||
* OpenFreeMap, the basemap under provider=maplibre, serves neither: measured, the glyph request is
|
||||
* a 404 and the labels never draw. So under MapLibre the labels use Noto Sans, which it does serve;
|
||||
* under Mapbox nothing changes.
|
||||
*
|
||||
* Spread into a layer's layout: layout: { 'text-field': …, ...labelFont(m) }
|
||||
*/
|
||||
export function labelFont(map, bold = false) {
|
||||
const ml = typeof window !== 'undefined' && window.maplibregl
|
||||
const isMaplibre = !!(ml && ml.Map && map instanceof ml.Map)
|
||||
if (isMaplibre) return { 'text-font': [bold ? 'Noto Sans Bold' : 'Noto Sans Regular'] }
|
||||
return bold ? { 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'] } : {}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
* "dataUrl":"<reached-neighbors.json>", "instanceId":"..." }
|
||||
*/
|
||||
|
||||
import { labelFont } from './labelFont.js'
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -318,7 +319,7 @@ function paintAll() {
|
|||
})
|
||||
if (!m.getLayer(L_CL_LABEL)) m.addLayer({
|
||||
id: L_CL_LABEL, type: 'symbol', source: SRC_CL, minzoom: 2.6,
|
||||
layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
|
||||
layout: { ...labelFont(m), 'text-field': ['get', 'label'], 'text-size': 11, 'text-offset': [0, 1.3],
|
||||
'text-anchor': 'top', 'text-allow-overlap': false, 'text-optional': true },
|
||||
paint: { 'text-color': C_RED_DK, 'text-halo-color': '#ffffff', 'text-halo-width': 1.4 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
* }
|
||||
*/
|
||||
|
||||
import { labelFont } from './labelFont.js'
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -308,7 +309,7 @@ function syncLayers() {
|
|||
id: L_SENDER_LABEL, type: 'symbol', source: SRC_SENDERS,
|
||||
minzoom: 3.4,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'], 'text-size': 10,
|
||||
...labelFont(m), 'text-field': ['get', 'name'], 'text-size': 10,
|
||||
'text-offset': [0, 1.1], 'text-anchor': 'top', 'text-allow-overlap': false
|
||||
},
|
||||
paint: { 'text-color': '#0b5e33', 'text-halo-color': '#ffffff', 'text-halo-width': 1.3 }
|
||||
|
|
@ -329,7 +330,7 @@ function syncLayers() {
|
|||
m.addLayer({
|
||||
id: L_TARGET_LABEL, type: 'symbol', source: SRC_TARGET,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'], 'text-size': 13, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
|
||||
'text-field': ['get', 'name'], 'text-size': 13, ...labelFont(m, true),
|
||||
'text-offset': [0, -1.4], 'text-anchor': 'bottom', 'text-allow-overlap': true
|
||||
},
|
||||
paint: { 'text-color': '#8a3800', 'text-halo-color': '#ffffff', 'text-halo-width': 1.6 }
|
||||
|
|
|
|||
14
plugin-designs/wall-of-people-groups/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>wall-of-people-groups (dev)</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;background:#f3f3f1}wall-of-people-groups{position:fixed;inset:0;display:block}</style>
|
||||
</head>
|
||||
<body>
|
||||
<wall-of-people-groups profile-config='{"profile":"wall-of-people-groups"}'></wall-of-people-groups>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
plugin-designs/wall-of-people-groups/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* wall-of-people-groups / index.js — bundle entry (emits wall-of-people-groups.js), defines
|
||||
* <wall-of-people-groups>. The standard ProfileLoader shape: the glob captures this folder's one
|
||||
* profile; its components live in src/. The wall's components read the store as this.$store (the
|
||||
* original app's global property), so it is created here, per element, on the element's Pinia.
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { usePeopleGroupsStore } from './src/stores/peopleGroupsStore.js'
|
||||
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
const El = defineCustomElement(ProfileLoader, {
|
||||
configureApp(app) {
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
const i18n = createAppI18n()
|
||||
app.use(i18n)
|
||||
app.provide('appI18n', i18n)
|
||||
app.provide('profileModules', profileModules)
|
||||
const store = usePeopleGroupsStore(pinia)
|
||||
store.loadFromLocalStorage()
|
||||
app.config.globalProperties.$store = store
|
||||
}
|
||||
})
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('wall-of-people-groups')) customElements.define('wall-of-people-groups', El)
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<template>
|
||||
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
|
||||
<div class="dialog-container" @click.stop>
|
||||
<!-- Dialog Header -->
|
||||
<div class="dialog-header">
|
||||
<h2>People Group Chats</h2>
|
||||
<button class="close-btn" @click="closeDialog">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- People Group Image (Instagram style) -->
|
||||
<div class="group-image-container">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group?.name || 'People Group'"
|
||||
class="group-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group-name-dialog">{{ group?.name || 'Unknown Group' }}</div>
|
||||
|
||||
<!-- People Group Info -->
|
||||
<div class="dialog-content">
|
||||
<div class="group-info">
|
||||
<div class="info-row">
|
||||
<strong>Status:</strong> {{ group?.status || 'Unknown' }}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<strong>ID:</strong> {{ group?.ID || 'N/A' }}
|
||||
</div>
|
||||
<div class="info-row" v-if="group?.lat && group?.lng">
|
||||
<strong>Location:</strong> {{ group.lat }}, {{ group.lng }}
|
||||
</div>
|
||||
<div class="info-row prayer-stat">
|
||||
<span class="heart-icon">❤️</span>
|
||||
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person has' : 'people have' }} prayed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prayer Button -->
|
||||
<div class="prayer-action">
|
||||
<button
|
||||
class="prayer-btn"
|
||||
@click="addPrayer"
|
||||
:class="{ active: justPrayed }"
|
||||
>
|
||||
{{ justPrayed ? '✓ You prayed!' : '🙏 Pray for this group' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section">
|
||||
<h3>Prayers & Comments</h3>
|
||||
|
||||
<!-- Comment List -->
|
||||
<div class="comments-list">
|
||||
<div
|
||||
v-if="comments.length === 0"
|
||||
class="no-comments"
|
||||
>
|
||||
No prayers or comments yet. Be the first!
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="comment-header">
|
||||
<span class="comment-icon">💭</span>
|
||||
<span class="comment-date">{{ formatDate(comment.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="comment-text">{{ comment.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Comment Form -->
|
||||
<div class="add-comment">
|
||||
<textarea
|
||||
v-model="newComment"
|
||||
placeholder="Share your prayer or thoughts..."
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
<div class="comment-actions">
|
||||
<span class="char-count">{{ newComment.length }}/500</span>
|
||||
<button
|
||||
@click="submitComment"
|
||||
:disabled="!newComment.trim()"
|
||||
class="submit-btn"
|
||||
>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
// Comments and Prayers Dialog Component
|
||||
export default {
|
||||
name: 'CommentsDialog',
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newComment: '',
|
||||
justPrayed: false,
|
||||
imageError: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
prayerCount() {
|
||||
if (!this.group || !this.$store) return 0;
|
||||
// Access the prayers state directly
|
||||
return this.$store.prayers?.[this.group.ID] || 0;
|
||||
},
|
||||
|
||||
comments() {
|
||||
if (!this.group || !this.$store) return [];
|
||||
// Access the comments state directly
|
||||
return this.$store.comments?.[this.group.ID] || [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('close');
|
||||
this.newComment = '';
|
||||
this.justPrayed = false;
|
||||
},
|
||||
|
||||
addPrayer() {
|
||||
if (this.group && this.$store) {
|
||||
this.$store.addPrayer(this.group.ID);
|
||||
this.justPrayed = true;
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
this.justPrayed = false;
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
submitComment() {
|
||||
if (this.newComment.trim() && this.group && this.$store) {
|
||||
this.$store.addComment(
|
||||
this.group.ID,
|
||||
this.newComment.trim()
|
||||
);
|
||||
this.newComment = '';
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The card's own photo (the live API's image_url); uupg.doxa.life no longer answers.
|
||||
return this.group?.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(newVal) {
|
||||
if (newVal) {
|
||||
// Reset state when dialog opens
|
||||
this.justPrayed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div
|
||||
class="people-group-card"
|
||||
:style="{ transform: 'translate(' + x + 'px, ' + y + 'px)' }"
|
||||
>
|
||||
<!-- Header with People Group Name -->
|
||||
<div class="card-header">
|
||||
<h3 class="group-name">
|
||||
<a
|
||||
v-if="hasJoshuaProjectLink()"
|
||||
:href="getJoshuaProjectUrl()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group-name-link"
|
||||
>
|
||||
{{ group.name || group.post_title || 'Unknown Group' }}
|
||||
</a>
|
||||
<span v-else class="group-name-no-link" title="This people group doesn't have a PeopleID3">
|
||||
{{ group.name || group.post_title || 'Unknown Group' }}
|
||||
</span>
|
||||
</h3>
|
||||
<span class="group-status">{{ group.status || 'Status Unknown' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Image Placeholder -->
|
||||
<div class="card-image" @click="$emit('view-detail', group)">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group.name"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (Instagram style) -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="action-btn heart-btn"
|
||||
@click="handlePrayer"
|
||||
:class="{ active: hasPrayed }"
|
||||
>
|
||||
<span class="icon">{{ hasPrayed ? '❤️' : '🤍' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn comment-btn"
|
||||
@click="$emit('open-comments', group)"
|
||||
>
|
||||
<span class="icon">💬</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn share-btn"
|
||||
@click="handleShare"
|
||||
>
|
||||
<span class="icon">📤</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prayer Count -->
|
||||
<div class="card-footer">
|
||||
<p class="prayer-count">
|
||||
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person' : 'people' }}
|
||||
prayed that they would receive God's love and the love of a missionary
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="view-comments-btn"
|
||||
@click="$emit('open-comments', group)"
|
||||
>
|
||||
View prayers and comments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
// People Group Card Component - Instagram style
|
||||
export default {
|
||||
name: 'PeopleGroupCard',
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageError: false,
|
||||
hasPrayed: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
prayerCount() {
|
||||
// Access the prayers state directly
|
||||
const groupId = this.group.id || this.group.PeopleID3;
|
||||
return this.$store?.prayers?.[groupId] || 0;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasJoshuaProjectLink() {
|
||||
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
|
||||
return peopleID3 && peopleID3.trim() !== '';
|
||||
},
|
||||
|
||||
getJoshuaProjectUrl() {
|
||||
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
|
||||
if (!peopleID3 || peopleID3.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return `https://joshuaproject.net/people_groups/${peopleID3}`;
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The live API's image_url; uupg.doxa.life no longer answers.
|
||||
return this.group.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
},
|
||||
|
||||
handlePrayer() {
|
||||
this.hasPrayed = !this.hasPrayed;
|
||||
if (this.hasPrayed) {
|
||||
this.$emit('add-prayer', this.group.id || this.group.PeopleID3);
|
||||
}
|
||||
},
|
||||
|
||||
handleShare() {
|
||||
alert('Sharing functionality coming later!');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
<template>
|
||||
<div class="wall-container">
|
||||
<div class="wall-header">
|
||||
<h1>Wall of People Groups</h1>
|
||||
<div class="wall-controls">
|
||||
<button @click="toggleOrientation" class="control-btn orientation-btn">
|
||||
<span class="btn-label">{{ isLandscape ? '📱 Switch to Portrait' : '🖼️ Switch to Landscape' }}</span>
|
||||
<span class="current-mode">Current: {{ isLandscape ? 'Landscape (4ft×3ft)' : 'Portrait (3ft×4ft)' }}</span>
|
||||
</button>
|
||||
<button @click="resetZoom" class="control-btn">Reset Zoom</button>
|
||||
<span class="group-count">{{ totalGroups }} Groups Loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="canvas" class="wall-canvas"></div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading people groups...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Wall of People Groups - D3 Zoomable Whiteboard
|
||||
export default {
|
||||
name: 'PeopleGroupsWall',
|
||||
data() {
|
||||
return {
|
||||
svg: null,
|
||||
zoom: null,
|
||||
cardWidth: 50,
|
||||
cardHeight: 70,
|
||||
cardSpacing: 8, // Increased horizontal spacing (was 2)
|
||||
cardVerticalSpacing: 3, // Separate vertical spacing
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
isLandscape: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
loading() {
|
||||
return this.$store?.loading || false;
|
||||
},
|
||||
|
||||
totalGroups() {
|
||||
return this.$store?.totalGroups || 0;
|
||||
},
|
||||
|
||||
peopleGroups() {
|
||||
return this.$store?.peopleGroups || [];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeCanvas();
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
console.log('📊 Wall component fetching data...');
|
||||
if (this.$store) {
|
||||
await this.$store.fetchPeopleGroups();
|
||||
console.log('🎨 Data fetched, rendering cards...');
|
||||
this.renderCards();
|
||||
} else {
|
||||
console.error('❌ Store not available!');
|
||||
}
|
||||
},
|
||||
|
||||
initializeCanvas() {
|
||||
// Set dimensions based on orientation
|
||||
if (this.isLandscape) {
|
||||
// 4ft x 3ft aspect ratio = 4:3 ratio (width:height) - LANDSCAPE
|
||||
this.canvasWidth = 4000; // 4ft wide
|
||||
this.canvasHeight = 3000; // 3ft tall
|
||||
} else {
|
||||
// 3ft x 4ft aspect ratio = 3:4 ratio (width:height) - PORTRAIT
|
||||
this.canvasWidth = 3000; // 3ft wide
|
||||
this.canvasHeight = 4000; // 4ft tall
|
||||
}
|
||||
|
||||
const container = d3.select(this.$refs.canvas);
|
||||
|
||||
// Create SVG
|
||||
this.svg = container
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.attr('viewBox', `0 0 ${this.canvasWidth} ${this.canvasHeight}`)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Create zoom behavior - start zoomed out to see everything
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 20])
|
||||
.filter(function(event) {
|
||||
// Allow zoom/pan except when trying to select text
|
||||
// Prevent zoom/pan when clicking on text elements with user-select enabled
|
||||
const target = event.target;
|
||||
|
||||
// Allow text selection on text elements
|
||||
if (target.tagName === 'text' || target.tagName === 'tspan') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow normal zoom/pan for everything else
|
||||
return !event.ctrlKey && !event.button;
|
||||
})
|
||||
.on('zoom', (event) => {
|
||||
this.svg.select('.cards-container')
|
||||
.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
// Add background
|
||||
this.svg.append('rect')
|
||||
.attr('width', this.canvasWidth)
|
||||
.attr('height', this.canvasHeight)
|
||||
.attr('fill', '#fafafa');
|
||||
|
||||
// Create container for cards
|
||||
this.svg.append('g')
|
||||
.attr('class', 'cards-container');
|
||||
},
|
||||
|
||||
renderCards() {
|
||||
console.log('🎨 Rendering cards...');
|
||||
console.log('People groups available:', this.peopleGroups?.length || 0);
|
||||
|
||||
if (!this.peopleGroups || this.peopleGroups.length === 0) {
|
||||
console.warn('⚠️ No people groups to render');
|
||||
return;
|
||||
}
|
||||
|
||||
const cardsContainer = this.svg.select('.cards-container');
|
||||
const totalGroups = this.peopleGroups.length;
|
||||
|
||||
// Calculate optimal grid layout based on 4:3 aspect ratio (LANDSCAPE)
|
||||
// Canvas is 4000x3000 (4:3 ratio - 4ft wide × 3ft tall)
|
||||
// We need to fit totalGroups cards
|
||||
|
||||
// Card aspect ratio (roughly portrait, like Instagram)
|
||||
const cardAspectRatio = this.cardWidth / this.cardHeight; // ~0.71
|
||||
|
||||
// Canvas aspect ratio
|
||||
const canvasAspectRatio = this.canvasWidth / this.canvasHeight; // 1.33 (4:3)
|
||||
|
||||
// Calculate columns and rows to maintain aspect ratio and fit all cards
|
||||
// cols / rows should approximate canvasAspectRatio
|
||||
// cols * rows should equal totalGroups
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(totalGroups * canvasAspectRatio));
|
||||
const rows = Math.ceil(totalGroups / cols);
|
||||
|
||||
console.log(`📐 Grid Layout: ${cols} columns × ${rows} rows = ${cols * rows} slots for ${totalGroups} groups`);
|
||||
console.log(`📏 Canvas: ${this.canvasWidth}×${this.canvasHeight} (aspect: ${canvasAspectRatio.toFixed(2)})`);
|
||||
|
||||
// Calculate card size to fit the grid
|
||||
const availableWidth = this.canvasWidth - (this.cardSpacing * (cols + 1));
|
||||
const availableHeight = this.canvasHeight - (this.cardSpacing * (rows + 1));
|
||||
|
||||
const calculatedCardWidth = availableWidth / cols;
|
||||
const calculatedCardHeight = availableHeight / rows;
|
||||
|
||||
// Use the smaller of the two to ensure cards fit
|
||||
this.cardWidth = Math.min(calculatedCardWidth, calculatedCardHeight * 0.7);
|
||||
this.cardHeight = this.cardWidth / 0.7; // Maintain card aspect ratio
|
||||
|
||||
console.log(`🎴 Card size: ${this.cardWidth.toFixed(1)}×${this.cardHeight.toFixed(1)}`);
|
||||
console.log('📦 Sample group data:', this.peopleGroups[0]);
|
||||
|
||||
const totalWidth = this.cardWidth + this.cardSpacing;
|
||||
const totalHeight = this.cardHeight + this.cardVerticalSpacing;
|
||||
|
||||
// Create card groups with better spacing
|
||||
const cardGroups = cardsContainer
|
||||
.selectAll('.card-group')
|
||||
.data(this.peopleGroups)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'card-group')
|
||||
.attr('transform', (d, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = col * totalWidth + this.cardSpacing;
|
||||
const y = row * totalHeight + this.cardVerticalSpacing;
|
||||
return `translate(${x}, ${y})`;
|
||||
});
|
||||
|
||||
// Add card background
|
||||
cardGroups
|
||||
.append('rect')
|
||||
.attr('width', this.cardWidth)
|
||||
.attr('height', this.cardHeight)
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', '#dbdbdb')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('rx', 0)
|
||||
.style('cursor', 'pointer')
|
||||
.style('pointer-events', 'none'); // Don't block text selection
|
||||
|
||||
// Calculate proportional font sizes - smaller for names
|
||||
const nameFontSize = Math.max(3.5, this.cardWidth * 0.07);
|
||||
const statusFontSize = Math.max(3, this.cardWidth * 0.055);
|
||||
const iconSize = Math.max(8, this.cardWidth * 0.12);
|
||||
const prayerFontSize = Math.max(3.5, this.cardWidth * 0.065);
|
||||
const cardWidth = this.cardWidth;
|
||||
const cardHeight = this.cardHeight;
|
||||
|
||||
// Add people group name - NO TRUNCATION, smaller font, with clickable link
|
||||
cardGroups.each(function(d) {
|
||||
const group = d3.select(this);
|
||||
const peopleID3 = d.peopleID3 || d.PeopleID3;
|
||||
const name = d.name || d.post_title || 'Unknown Group';
|
||||
|
||||
let nameElement;
|
||||
|
||||
// If has peopleID3, wrap in a link
|
||||
if (peopleID3 && peopleID3.trim() !== '') {
|
||||
const link = group.append('a')
|
||||
.attr('href', `https://joshuaproject.net/people_groups/${peopleID3}`)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
nameElement = link.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', cardHeight * 0.06)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', nameFontSize)
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#0095f6') // Blue for links
|
||||
.attr('text-decoration', 'underline')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all');
|
||||
} else {
|
||||
// No link, just text
|
||||
nameElement = group.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', cardHeight * 0.06)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', nameFontSize)
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#262626') // Dark gray for non-links
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all');
|
||||
}
|
||||
|
||||
// Try to fit in one line first
|
||||
nameElement.text(name);
|
||||
|
||||
// If too long, wrap to two lines
|
||||
if (nameElement.node().getComputedTextLength() > cardWidth * 0.95) {
|
||||
nameElement.text('');
|
||||
const words = name.split(' ');
|
||||
const mid = Math.ceil(words.length / 2);
|
||||
const line1 = words.slice(0, mid).join(' ');
|
||||
const line2 = words.slice(mid).join(' ');
|
||||
|
||||
nameElement.append('tspan')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('dy', 0)
|
||||
.text(line1);
|
||||
|
||||
nameElement.append('tspan')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('dy', nameFontSize * 1.1)
|
||||
.text(line2);
|
||||
}
|
||||
});
|
||||
|
||||
// Add status (moved down to accommodate 2-line names)
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', this.cardHeight * 0.14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', statusFontSize)
|
||||
.attr('fill', '#8e8e8e')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all')
|
||||
.text(d => d.status || '');
|
||||
|
||||
// Add image or placeholder - FULL WIDTH like Instagram
|
||||
const imageY = this.cardHeight * 0.18;
|
||||
const imageHeight = this.cardHeight * 0.50; // Larger image
|
||||
const imageWidth = this.cardWidth; // FULL WIDTH
|
||||
|
||||
// Background rectangle for image area
|
||||
cardGroups
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', imageY)
|
||||
.attr('width', imageWidth)
|
||||
.attr('height', imageHeight)
|
||||
.attr('fill', '#fafafa')
|
||||
.attr('stroke', '#efefef')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('rx', 0); // NO rounded corners
|
||||
|
||||
// Add images or placeholders
|
||||
cardGroups.each(function(d) {
|
||||
const group = d3.select(this);
|
||||
|
||||
if (d.imageUrl) {
|
||||
// Add SVG image element with proper aspect ratio
|
||||
group
|
||||
.append('image')
|
||||
.attr('x', 0)
|
||||
.attr('y', imageY)
|
||||
.attr('width', imageWidth)
|
||||
.attr('height', imageHeight)
|
||||
.attr('href', d.imageUrl)
|
||||
.attr('preserveAspectRatio', 'xMidYMid slice')
|
||||
.style('pointer-events', 'none')
|
||||
.on('error', function() {
|
||||
console.log('Image failed to load:', d.imageUrl);
|
||||
});
|
||||
} else {
|
||||
// Add placeholder icon/text if no image URL
|
||||
group
|
||||
.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', imageY + imageHeight / 2)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', iconSize * 1.5)
|
||||
.attr('fill', '#c7c7c7')
|
||||
.text('📷')
|
||||
.style('pointer-events', 'none');
|
||||
}
|
||||
});
|
||||
|
||||
// Add action buttons row with FontAwesome icons - Instagram style
|
||||
const buttonY = this.cardHeight * 0.72;
|
||||
const buttonSize = iconSize * 0.7; // Make buttons smaller (was iconSize)
|
||||
const buttonSpacing = buttonSize * 1.6; // Tighter spacing
|
||||
const totalButtonWidth = (buttonSize * 3) + (buttonSpacing * 2);
|
||||
const startX = (cardWidth - totalButtonWidth) / 2; // Center the button group
|
||||
|
||||
// Heart button (using foreignObject for FontAwesome)
|
||||
const heartButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openPrayerDialog(d);
|
||||
});
|
||||
|
||||
heartButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html(function(d) {
|
||||
// Combine API prayer count with any local session prayers
|
||||
const apiCount = Number(d.peoplePraying || 0);
|
||||
const localCount = self.$store?.prayers?.[d.ID] || 0;
|
||||
const prayerCount = apiCount + localCount;
|
||||
const iconClass = prayerCount > 0 ? 'fas fa-heart' : 'far fa-heart';
|
||||
const iconColor = prayerCount > 0 ? '#ed4956' : '#262626';
|
||||
return '<i class="' + iconClass + '" style="font-size: ' + buttonSize + 'px; color: ' + iconColor + ';"></i>';
|
||||
});
|
||||
|
||||
// Comment button
|
||||
const commentButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + buttonSpacing)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openComments(d);
|
||||
});
|
||||
|
||||
commentButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-comment" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// Share button
|
||||
const shareButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + (buttonSpacing * 2))
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
alert('Sharing functionality coming later!');
|
||||
});
|
||||
|
||||
shareButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-paper-plane" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// Add prayer count text
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY + buttonSize + prayerFontSize + 2)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', prayerFontSize)
|
||||
.attr('fill', '#262626')
|
||||
.attr('font-weight', '600')
|
||||
.attr('class', 'prayer-text')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all')
|
||||
.text(d => {
|
||||
// Combine API prayer count with any local session prayers
|
||||
const apiCount = Number(d.peoplePraying || 0);
|
||||
const localCount = this.$store?.prayers?.[d.ID] || 0;
|
||||
const count = apiCount + localCount;
|
||||
if (count === 0) return '0 prayed';
|
||||
return `${count} ${count === 1 ? 'person' : 'people'} prayed`;
|
||||
});
|
||||
|
||||
// Add "View prayers" button
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY + buttonSize + (prayerFontSize * 2) + 4)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', prayerFontSize)
|
||||
.attr('fill', '#8e8e8e')
|
||||
.attr('cursor', 'pointer')
|
||||
.attr('class', 'view-comments-text')
|
||||
.style('user-select', 'none')
|
||||
.style('pointer-events', 'all')
|
||||
.text('View prayers')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openComments(d);
|
||||
});
|
||||
|
||||
console.log(`✅ Rendered ${totalGroups} cards in ${cols}×${rows} grid`);
|
||||
},
|
||||
|
||||
handlePrayer(group) {
|
||||
// This method is deprecated - now using openPrayerDialog
|
||||
this.openPrayerDialog(group);
|
||||
},
|
||||
|
||||
openPrayerDialog(group) {
|
||||
this.$emit('open-prayer', group);
|
||||
},
|
||||
|
||||
openComments(group) {
|
||||
this.$emit('open-comments', group);
|
||||
},
|
||||
|
||||
truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
},
|
||||
|
||||
resetZoom() {
|
||||
this.svg.transition()
|
||||
.duration(750)
|
||||
.call(this.zoom.transform, d3.zoomIdentity);
|
||||
},
|
||||
|
||||
toggleOrientation() {
|
||||
this.isLandscape = !this.isLandscape;
|
||||
|
||||
// Clear existing canvas
|
||||
d3.select(this.$refs.canvas).selectAll('*').remove();
|
||||
|
||||
// Reinitialize with new orientation
|
||||
this.initializeCanvas();
|
||||
|
||||
// Re-render cards
|
||||
if (this.peopleGroups && this.peopleGroups.length > 0) {
|
||||
this.renderCards();
|
||||
}
|
||||
|
||||
console.log(`📐 Switched to ${this.isLandscape ? 'LANDSCAPE (4ft×3ft)' : 'PORTRAIT (3ft×4ft)'}`);
|
||||
},
|
||||
|
||||
printWall() {
|
||||
alert('Print functionality coming later!');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div v-if="isOpen" class="dialog-overlay" @click.self="closeDialog">
|
||||
<div class="dialog-container prayer-dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Prayer Tools</h2>
|
||||
<button @click="closeDialog" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="group-image-container">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group?.name || 'People Group'"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group-name-dialog">
|
||||
{{ group?.name || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="group-info">
|
||||
<div class="info-row">
|
||||
<strong>Status:</strong> {{ group?.status || 'unengaged' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasPrayed" class="prayer-prompt">
|
||||
<h3>Are you praying right now for this people group?</h3>
|
||||
<button @click="confirmPrayer" class="prayer-confirm-btn">
|
||||
<i class="far fa-heart"></i>
|
||||
Yes, I'm Praying
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="prayer-confirmed">
|
||||
<div class="confirmed-message">
|
||||
<i class="fas fa-heart" style="color: #ed4956;"></i>
|
||||
<span>Thank you for praying!</span>
|
||||
</div>
|
||||
|
||||
<div class="prayer-input-section">
|
||||
<h4>Share your prayer (optional)</h4>
|
||||
<textarea
|
||||
v-model="prayerText"
|
||||
placeholder="Type your prayer here..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
<button
|
||||
@click="sendPrayer"
|
||||
:disabled="!prayerText.trim()"
|
||||
class="send-prayer-btn"
|
||||
>
|
||||
Send Prayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
export default {
|
||||
name: 'PrayerDialog',
|
||||
props: {
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasPrayed: false,
|
||||
prayerText: '',
|
||||
imageError: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
isOpen(newVal) {
|
||||
if (newVal) {
|
||||
// Check if user already prayed for this group
|
||||
// Access the prayers state directly
|
||||
const prayerCount = this.$store?.prayers?.[this.group?.ID] || 0;
|
||||
this.hasPrayed = prayerCount > 0;
|
||||
this.prayerText = '';
|
||||
this.imageError = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
confirmPrayer() {
|
||||
if (this.group?.ID) {
|
||||
this.$store.addPrayer(this.group.ID);
|
||||
this.hasPrayed = true;
|
||||
|
||||
// Emit event to update the card's heart icon
|
||||
this.$emit('prayer-confirmed', this.group.ID);
|
||||
}
|
||||
},
|
||||
|
||||
sendPrayer() {
|
||||
if (this.prayerText.trim() && this.group?.ID) {
|
||||
// Add prayer to comments as a special type
|
||||
const pseudonym = this.generatePseudonym();
|
||||
const prayerComment = `🙏 ${pseudonym} prayed: ${this.prayerText.trim()}`;
|
||||
|
||||
this.$store.addComment(this.group.ID, prayerComment);
|
||||
|
||||
this.prayerText = '';
|
||||
|
||||
// Close dialog after sending
|
||||
setTimeout(() => {
|
||||
this.closeDialog();
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
|
||||
generatePseudonym() {
|
||||
const adjectives = ['Faithful', 'Devoted', 'Humble', 'Earnest', 'Hopeful', 'Grateful', 'Compassionate'];
|
||||
const nouns = ['Servant', 'Believer', 'Intercessor', 'Warrior', 'Witness', 'Disciple', 'Friend'];
|
||||
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const num = Math.floor(Math.random() * 999) + 1;
|
||||
|
||||
return `${adj} ${noun} ${num}`;
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The same photo the card shows (the live API's image_url). This used to build a URL on
|
||||
// uupg.doxa.life, which no longer answers, so every dialog fell back to "No image".
|
||||
return this.group?.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div v-if="sources.length > 1">
|
||||
<div class="source-selector">
|
||||
<div class="source-header">
|
||||
<h3>📊 Data Source</h3>
|
||||
<span class="current-source-label">Current: {{ currentSource?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="source-buttons">
|
||||
<button
|
||||
v-for="source in availableSources"
|
||||
:key="source.id"
|
||||
@click="selectSource(source.id)"
|
||||
:class="['source-btn', { active: dataSource === source.id }]"
|
||||
>
|
||||
<span class="source-icon">{{ getSourceIcon(source.id) }}</span>
|
||||
<span class="source-name">{{ source.name }}</span>
|
||||
<span v-if="dataSource === source.id" class="active-indicator">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<p v-if="currentSource">
|
||||
<strong>Source URL:</strong><br>
|
||||
<code>{{ currentSource.url }}</code>
|
||||
</p>
|
||||
<p v-if="dataSource === 'CSV'" class="csv-info">
|
||||
<i class="fas fa-info-circle"></i> CSV includes ~1,439 groups with images from Joshua Project
|
||||
</p>
|
||||
<p v-if="dataSource === 'DOXA_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> WordPress API — descriptions & photos, no prayer data
|
||||
</p>
|
||||
<p v-if="dataSource === 'PRAY_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> Live prayer data — people praying, committed workers, adoption, images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Source Selector Component - Switch between API and CSV data sources
|
||||
export default {
|
||||
name: 'SourceSelector',
|
||||
computed: {
|
||||
dataSource() {
|
||||
return this.$store?.dataSource || 'API';
|
||||
},
|
||||
|
||||
availableSources() {
|
||||
return this.$store?.availableSources || [];
|
||||
},
|
||||
|
||||
currentSource() {
|
||||
return this.$store?.currentSource || null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectSource(sourceId) {
|
||||
console.log('🔄 Switching to source:', sourceId);
|
||||
if (this.$store) {
|
||||
this.$store.setDataSource(sourceId);
|
||||
}
|
||||
},
|
||||
|
||||
getSourceIcon(sourceId) {
|
||||
switch(sourceId) {
|
||||
case 'PRAY_API':
|
||||
return '🙏';
|
||||
case 'DOXA_API':
|
||||
return '🌐';
|
||||
case 'API':
|
||||
return '🌐';
|
||||
case 'CSV':
|
||||
return '📄';
|
||||
default:
|
||||
return '📊';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
// People Groups Store - Manages data from the Doxa prayer API
|
||||
// Moved 2026-09-25 from the wall-of-people-groups no-build SPA: Pinia now comes from the package
|
||||
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
|
||||
// cannot collide with the host page's own keys, and the source list is the one live API.
|
||||
import { defineStore } from 'pinia'
|
||||
const LS = (k) => 'wall-pg:' + k
|
||||
|
||||
export const usePeopleGroupsStore = (pinia) => {
|
||||
|
||||
return defineStore('peopleGroups', {
|
||||
state: () => ({
|
||||
peopleGroups: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
prayers: {}, // Track prayers per people group ID
|
||||
comments: {}, // Track comments per people group ID
|
||||
dataSource: 'PRAY_API', // 'PRAY_API', 'DOXA_API', or 'CSV'
|
||||
// The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV fallback was a
|
||||
// 2.6 MB copy of what the live API already serves, so neither ships.
|
||||
availableSources: [
|
||||
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
|
||||
]
|
||||
}),
|
||||
|
||||
getters: {
|
||||
totalGroups: (state) => state.peopleGroups.length,
|
||||
|
||||
getPrayerCount: (state) => (groupId) => {
|
||||
return state.prayers[groupId] || 0;
|
||||
},
|
||||
|
||||
getComments: (state) => (groupId) => {
|
||||
return state.comments[groupId] || [];
|
||||
},
|
||||
|
||||
currentSource: (state) => {
|
||||
return state.availableSources.find(s => s.id === state.dataSource);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchPeopleGroups() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
console.log(`🔄 Fetching people groups from ${this.dataSource}...`);
|
||||
|
||||
try {
|
||||
if (this.dataSource === 'PRAY_API') {
|
||||
await this.fetchFromPrayAPI();
|
||||
} else if (this.dataSource === 'DOXA_API') {
|
||||
await this.fetchFromAPI();
|
||||
} else if (this.dataSource === 'CSV') {
|
||||
await this.fetchFromCSV();
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully loaded ${this.peopleGroups.length} people groups from ${this.dataSource}!`);
|
||||
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
|
||||
this.error = error.message;
|
||||
this.peopleGroups = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFromPrayAPI() {
|
||||
const source = this.availableSources.find(s => s.id === 'PRAY_API');
|
||||
const apiUrl = source.url;
|
||||
console.log('🙏 Prayer.Tools API URL:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Guard against HTML responses (bot protection, Nuxt SPA shell)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||
|
||||
if (posts.length === 0) {
|
||||
console.error('Unexpected API response format:', data);
|
||||
this.peopleGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Placeholder for groups without photos
|
||||
const noPhotoPlaceholder = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
this.peopleGroups = posts.map(group => {
|
||||
// Extract labels from nested value/label objects
|
||||
const regionLabel = group.wagf_region?.label || group.wagf_region || '';
|
||||
const blockLabel = group.wagf_block?.label || group.wagf_block || '';
|
||||
const countryLabel = group.country_code?.label || group.country_code || '';
|
||||
const countryCode = group.country_code?.value || group.country_code || '';
|
||||
const religionLabel = group.religion?.label || group.religion || '';
|
||||
const religionCode = group.religion?.value || '';
|
||||
const affinityLabel = group.rop1?.label || '';
|
||||
|
||||
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
|
||||
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
|
||||
const imageUrl = group.image_url || noPhotoPlaceholder;
|
||||
|
||||
return {
|
||||
// Standard fields
|
||||
ID: group.slug || `pray-${Math.random().toString(36).slice(2)}`,
|
||||
post_title: group.name || 'Unknown',
|
||||
name: group.name || 'Unknown',
|
||||
slug: group.slug || '',
|
||||
status: '',
|
||||
country: countryLabel,
|
||||
countryCode: countryCode,
|
||||
region: regionLabel,
|
||||
wagfBlock: blockLabel,
|
||||
population: group.population || '',
|
||||
religion: religionLabel,
|
||||
religionCode: religionCode,
|
||||
affinityBlock: affinityLabel,
|
||||
latitude: group.latitude || null,
|
||||
longitude: group.longitude || null,
|
||||
description: group.location_description || '',
|
||||
|
||||
// Prayer & engagement data (THE KEY FIELDS!)
|
||||
peoplePraying: Number(group.people_praying || 0),
|
||||
peopleCommitted: Number(group.people_committed || 0),
|
||||
adoptedByChurches: Number(group.adopted_by_churches || 0),
|
||||
|
||||
// Image fields
|
||||
imageUrl: imageUrl,
|
||||
hasPhoto: hasPhoto,
|
||||
photoCredit: group.picture_credit || null,
|
||||
imageStatus: hasPhoto ? 'pending' : 'placeholder',
|
||||
|
||||
// Keep all original API data
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
// Log prayer & engagement statistics
|
||||
const withPrayer = this.peopleGroups.filter(g => g.peoplePraying > 0).length;
|
||||
const withCommitted = this.peopleGroups.filter(g => g.peopleCommitted > 0).length;
|
||||
const withAdoption = this.peopleGroups.filter(g => g.adoptedByChurches > 0).length;
|
||||
const withImages = this.peopleGroups.filter(g => g.hasPhoto).length;
|
||||
|
||||
console.log(`📊 Prayer.Tools API stats:`);
|
||||
console.log(` Total groups: ${this.peopleGroups.length}`);
|
||||
console.log(` 🙏 With prayer: ${withPrayer}`);
|
||||
console.log(` 👥 With committed workers: ${withCommitted}`);
|
||||
console.log(` ⛪ Adopted by churches: ${withAdoption}`);
|
||||
console.log(` 📸 With photos: ${withImages}`);
|
||||
|
||||
// Start background image testing
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async fetchFromAPI() {
|
||||
const source = this.availableSources.find(s => s.id === 'DOXA_API');
|
||||
const apiUrl = source.url;
|
||||
console.log('API URL:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Guard against HTML responses (bot protection, error pages)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Placeholder image for groups without photos
|
||||
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
// The API returns { "posts": [...] }
|
||||
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||
|
||||
if (posts.length === 0) {
|
||||
console.error('Unexpected API response format:', data);
|
||||
this.peopleGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.peopleGroups = posts.map(group => {
|
||||
// Determine image URL from API data
|
||||
const hasPhoto = group.imb_has_photo === '1' || group.imb_has_photo === 1 || group.imb_has_photo === true;
|
||||
const apiImageUrl = group.imb_picture_url || null;
|
||||
const imageUrl = (hasPhoto && apiImageUrl) ? apiImageUrl : placeholderUrl;
|
||||
|
||||
return {
|
||||
// Standard fields mapped from API
|
||||
ID: group.dt_id || group.ID || `api-${Math.random().toString(36).slice(2)}`,
|
||||
post_title: group.imb_display_name || group.post_title || 'Unknown',
|
||||
name: group.imb_display_name || group.post_title || 'Unknown',
|
||||
slug: group.slug || '',
|
||||
status: group.status || '',
|
||||
country: group.imb_isoalpha3 || '',
|
||||
region: group.doxa_wagf_region || '',
|
||||
wagfBlock: group.doxa_wagf_block || '',
|
||||
population: group.imb_population || '',
|
||||
religion: group.imb_reg_of_religion || '',
|
||||
religionCode: group.imb_reg_of_religion_3 || '',
|
||||
latitude: group.imb_lat || null,
|
||||
longitude: group.imb_lng || null,
|
||||
description: group.imb_people_description || '',
|
||||
|
||||
// Image fields
|
||||
imageUrl: imageUrl,
|
||||
hasPhoto: hasPhoto,
|
||||
photoCredit: group.imb_picture_credit_html || '',
|
||||
imageStatus: hasPhoto && apiImageUrl ? 'pending' : 'placeholder',
|
||||
|
||||
// Keep all original API data
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
// Log image statistics
|
||||
const withImages = this.peopleGroups.filter(g => g.hasPhoto && g.imb_picture_url).length;
|
||||
const withPlaceholder = this.peopleGroups.length - withImages;
|
||||
console.log(`📸 Image stats: ${withImages} with photos, ${withPlaceholder} using placeholder`);
|
||||
|
||||
// Start background image testing for groups that have URLs
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async fetchFromCSV() {
|
||||
const source = this.availableSources.find(s => s.id === 'CSV');
|
||||
const csvUrl = source.url;
|
||||
console.log('CSV URL:', csvUrl);
|
||||
|
||||
const response = await fetch(csvUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const csvText = await response.text();
|
||||
const parsedData = this.parseCSV(csvText);
|
||||
|
||||
// Map all groups to standard format
|
||||
const allGroups = parsedData.map(group => ({
|
||||
ID: group.PEID || group.PeopleID3 || `csv-${Math.random()}`,
|
||||
post_title: group.Name || group.PplNm,
|
||||
name: group.Name || group.PplNm,
|
||||
status: group.Status || 'Active',
|
||||
country: group.Ctry,
|
||||
region: group.Regn,
|
||||
population: group.Pop,
|
||||
language: group.Lang,
|
||||
religion: group.Rlgn,
|
||||
peopleID3: group.PeopleID3 && group.PeopleID3.trim() !== '' ? group.PeopleID3.trim() : null,
|
||||
imageUrl: group.PeopleID3 && group.PeopleID3.trim() !== '' ? this.generateImageUrl(group.PeopleID3.trim()) : null,
|
||||
imageStatus: 'pending', // 'pending', 'loading', 'loaded', 'failed', 'error'
|
||||
// Include all original CSV data
|
||||
...group
|
||||
}));
|
||||
|
||||
// Count cluster statistics
|
||||
const clusterStats = {};
|
||||
allGroups.forEach(group => {
|
||||
if (group.peopleID3) {
|
||||
if (!clusterStats[group.peopleID3]) {
|
||||
clusterStats[group.peopleID3] = { total: 0 };
|
||||
}
|
||||
clusterStats[group.peopleID3].total++;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial sort by cluster size
|
||||
this.peopleGroups = allGroups.sort((a, b) => {
|
||||
const aHasID = a.peopleID3 ? 1 : 0;
|
||||
const bHasID = b.peopleID3 ? 1 : 0;
|
||||
|
||||
// Groups with PeopleID3 come before groups without
|
||||
if (aHasID !== bHasID) {
|
||||
return bHasID - aHasID;
|
||||
}
|
||||
|
||||
// Both have PeopleID3: sort by cluster size
|
||||
if (aHasID && bHasID) {
|
||||
const aCluster = clusterStats[a.peopleID3];
|
||||
const bCluster = clusterStats[b.peopleID3];
|
||||
|
||||
if (aCluster.total !== bCluster.total) {
|
||||
return bCluster.total - aCluster.total;
|
||||
}
|
||||
|
||||
return a.peopleID3.localeCompare(b.peopleID3);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const withPeopleID3 = this.peopleGroups.filter(g => g.peopleID3).length;
|
||||
const withoutPeopleID3 = this.peopleGroups.length - withPeopleID3;
|
||||
const uniqueClusters = Object.keys(clusterStats).length;
|
||||
|
||||
console.log(`✅ Parsed ${parsedData.length} total rows`);
|
||||
console.log(` ${withPeopleID3} have PeopleID3, ${withoutPeopleID3} without`);
|
||||
console.log(` ${uniqueClusters} unique PeopleID3 clusters`);
|
||||
console.log('Top 5 largest clusters:',
|
||||
Object.entries(clusterStats)
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.slice(0, 5)
|
||||
.map(([id, stats]) => `PeopleID3 ${id}: ${stats.total} groups`));
|
||||
|
||||
// Start background image testing (non-blocking)
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async testImagesInBackground() {
|
||||
console.log('🔍 Starting background image verification...');
|
||||
|
||||
// Get groups that need image testing (have a real imageUrl, not placeholder)
|
||||
const groupsToTest = this.peopleGroups.filter(g =>
|
||||
g.imageUrl && g.imageStatus === 'pending'
|
||||
);
|
||||
|
||||
// Deduplicate by imageUrl
|
||||
const uniqueTests = new Map();
|
||||
groupsToTest.forEach(group => {
|
||||
if (!uniqueTests.has(group.imageUrl)) {
|
||||
uniqueTests.set(group.imageUrl, {
|
||||
url: group.imageUrl,
|
||||
groupIds: [group.ID],
|
||||
status: 'pending'
|
||||
});
|
||||
} else {
|
||||
uniqueTests.get(group.imageUrl).groupIds.push(group.ID);
|
||||
}
|
||||
});
|
||||
|
||||
const totalToTest = uniqueTests.size;
|
||||
let tested = 0;
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
let errors = 0;
|
||||
|
||||
console.log(` Testing ${totalToTest} unique images...`);
|
||||
|
||||
// Test images SLOWLY - only 3 at a time with delays
|
||||
const batchSize = 3; // Small batch size to avoid rate limiting
|
||||
const delayBetweenBatches = 1000; // 1 second delay between batches
|
||||
const maxRetries = 3; // Retry failed images up to 3 times
|
||||
|
||||
const entries = Array.from(uniqueTests.entries());
|
||||
|
||||
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
for (let i = 0; i < entries.length; i += batchSize) {
|
||||
const batch = entries.slice(i, i + batchSize);
|
||||
const promises = batch.map(([imageUrl, info]) =>
|
||||
this.testImageWithRetry(info.url, null, maxRetries).then(result => ({
|
||||
imageUrl,
|
||||
groupIds: info.groupIds,
|
||||
...result
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Update groups with results
|
||||
results.forEach(result => {
|
||||
const { groupIds, status, attempts } = result;
|
||||
|
||||
this.peopleGroups.forEach(group => {
|
||||
if (groupIds.includes(group.ID)) {
|
||||
group.imageStatus = status;
|
||||
group.imageAttempts = attempts;
|
||||
if (status === 'failed' || status === 'error') {
|
||||
// Fall back to placeholder
|
||||
group.imageUrl = placeholderUrl;
|
||||
group.imageStatus = 'placeholder';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tested++;
|
||||
if (status === 'loaded') {
|
||||
loaded++;
|
||||
} else if (status === 'failed') {
|
||||
failed++;
|
||||
console.warn(` ❌ 404: ${result.imageUrl} (tried ${attempts} times)`);
|
||||
} else if (status === 'error') {
|
||||
errors++;
|
||||
console.error(` ⚠️ Error: ${result.imageUrl} (tried ${attempts} times)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Log progress every 10 batches
|
||||
if ((i / batchSize) % 10 === 0) {
|
||||
console.log(` Progress: ${tested}/${totalToTest} tested (${loaded} ✅, ${failed} ❌, ${errors} ⚠️)`);
|
||||
}
|
||||
|
||||
// Delay between batches to avoid rate limiting
|
||||
if (i + batchSize < entries.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Image testing complete!`);
|
||||
console.log(` ✅ ${loaded} images loaded successfully`);
|
||||
console.log(` ❌ ${failed} images not found (404) after retries`);
|
||||
console.log(` ⚠️ ${errors} network/timeout errors after retries`);
|
||||
console.log(` Note: Errors may be due to rate limiting or slow network`);
|
||||
},
|
||||
|
||||
async testImageWithRetry(url, peopleID3, maxRetries = 3) {
|
||||
let lastResult = null;
|
||||
|
||||
// First, try the primary URL (media.joshuaproject.net with /public/)
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const result = await this.testImageLoadDetailed(url);
|
||||
|
||||
// If loaded successfully, return immediately
|
||||
if (result.status === 'loaded') {
|
||||
return { ...result, attempts: attempt, urlFormat: 'primary' };
|
||||
}
|
||||
|
||||
lastResult = result;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Wait before retrying (exponential backoff)
|
||||
const delay = 500 * attempt; // 500ms, 1000ms, 1500ms...
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// Primary URL failed, try fallback URL format
|
||||
const fallbackUrl = `https://joshuaproject.net/assets/media/profiles/photos/p${peopleID3}.jpg`;
|
||||
console.log(` 🔄 Trying fallback URL for PeopleID3 ${peopleID3}...`);
|
||||
|
||||
const fallbackResult = await this.testImageLoadDetailed(fallbackUrl);
|
||||
|
||||
if (fallbackResult.status === 'loaded') {
|
||||
// Update the group's imageUrl to use the fallback
|
||||
this.peopleGroups.forEach(group => {
|
||||
if (group.peopleID3 === peopleID3) {
|
||||
group.imageUrl = fallbackUrl;
|
||||
}
|
||||
});
|
||||
return { ...fallbackResult, attempts: maxRetries + 1, urlFormat: 'fallback' };
|
||||
}
|
||||
|
||||
// Both primary and fallback failed
|
||||
return { ...lastResult, attempts: maxRetries + 1, urlFormat: 'none' };
|
||||
},
|
||||
|
||||
testImageLoadDetailed(url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''; // Cancel loading
|
||||
resolve({ status: 'error', statusCode: 'timeout', url });
|
||||
}, 10000); // 10 second timeout (longer for slow networks)
|
||||
|
||||
img.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ status: 'loaded', statusCode: 200, url });
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
// Assume failed (could be 404 or network error)
|
||||
resolve({ status: 'failed', statusCode: 404, url });
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
|
||||
parseCSV(csvText) {
|
||||
// Split by newline but handle quoted fields with embedded newlines
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
currentLine += char;
|
||||
} else if (char === '\n' && !inQuotes) {
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = '';
|
||||
} else {
|
||||
currentLine += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last line if exists
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
// Get headers from first line - parse CSV properly
|
||||
const headers = this.parseCSVLine(lines[0]);
|
||||
console.log('CSV Headers found:', headers.length, 'columns');
|
||||
console.log('Key columns - PEID:', headers.indexOf('PEID'), 'Name:', headers.indexOf('Name'), 'PeopleID3:', headers.indexOf('PeopleID3'));
|
||||
|
||||
const data = [];
|
||||
|
||||
// Parse each line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const values = this.parseCSVLine(line);
|
||||
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
if (values[index] !== undefined) {
|
||||
row[header] = values[index];
|
||||
}
|
||||
});
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
parseCSVLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
generateImageUrl(peopleID3) {
|
||||
if (!peopleID3 || peopleID3.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Primary URL format (works better): https://media.joshuaproject.net/public/assets/media/profiles/photos/p19007.jpg
|
||||
// Fallback URL format: https://joshuaproject.net/assets/media/profiles/photos/p18194.jpg
|
||||
return `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${peopleID3}.jpg`;
|
||||
},
|
||||
|
||||
setDataSource(sourceId) {
|
||||
if (this.dataSource !== sourceId) {
|
||||
this.dataSource = sourceId;
|
||||
// Save preference to localStorage
|
||||
localStorage.setItem(LS('dataSource'), sourceId);
|
||||
// Refetch data from new source
|
||||
this.fetchPeopleGroups();
|
||||
}
|
||||
},
|
||||
|
||||
addPrayer(groupId) {
|
||||
if (!this.prayers[groupId]) {
|
||||
this.prayers[groupId] = 0;
|
||||
}
|
||||
this.prayers[groupId]++;
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(LS('prayers'), JSON.stringify(this.prayers));
|
||||
},
|
||||
|
||||
addComment(groupId, comment) {
|
||||
if (!this.comments[groupId]) {
|
||||
this.comments[groupId] = [];
|
||||
}
|
||||
|
||||
this.comments[groupId].push({
|
||||
id: Date.now(),
|
||||
text: comment,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(LS('comments'), JSON.stringify(this.comments));
|
||||
},
|
||||
|
||||
loadFromLocalStorage() {
|
||||
try {
|
||||
const savedPrayers = localStorage.getItem(LS('prayers'));
|
||||
const savedComments = localStorage.getItem(LS('comments'));
|
||||
const savedSource = localStorage.getItem(LS('dataSource'));
|
||||
|
||||
if (savedPrayers) {
|
||||
this.prayers = JSON.parse(savedPrayers);
|
||||
}
|
||||
|
||||
if (savedComments) {
|
||||
this.comments = JSON.parse(savedComments);
|
||||
}
|
||||
|
||||
if (savedSource && ['PRAY_API'].includes(savedSource)) {
|
||||
this.dataSource = savedSource;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading from localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
})(pinia);
|
||||
};
|
||||
|
||||
|
||||
|
||||
1162
plugin-designs/wall-of-people-groups/src/wall.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "monolithic",
|
||||
"label": "Wall of People Groups",
|
||||
"note": "Monolithic composite (capability 3): ONE root .vue nests the zoomable d3 wall, its cards, and the prayer and comments dialogs. index.js registers exactly ONE tag (wall-of-people-groups); none of the inner components is independently embeddable."
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script>
|
||||
/**
|
||||
* wall-of-people-groups.vue — Wall of People Groups: every Doxa people group on one zoomable wall.
|
||||
*
|
||||
* @description Every Doxa people group as a card on one zoomable prayer wall
|
||||
* @profile wall-of-people-groups
|
||||
* @bundle wall-of-people-groups
|
||||
* @element wall-of-people-groups
|
||||
*
|
||||
* MOVED 2026-09-25 from the wall-of-people-groups no-build SPA (far-fast-tools, read-only). Its
|
||||
* components were plain objects with template strings compiled in the browser; they are now SFCs
|
||||
* in src/components. This file is the app container from its main.js, without the router (the
|
||||
* only other route was an API developer panel, which does not ship).
|
||||
*
|
||||
* Data: the live pray.doxa.life people-group list (2,107 groups, with photos and prayer counts).
|
||||
* Prayers and comments stay in this browser's localStorage, under a 'wall-pg:' prefix.
|
||||
* d3 and Font Awesome are CDN globals, loaded on first mount.
|
||||
*/
|
||||
import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
|
||||
import CommentsDialog from './src/components/CommentsDialog.vue'
|
||||
import PrayerDialog from './src/components/PrayerDialog.vue'
|
||||
import SourceSelector from './src/components/SourceSelector.vue'
|
||||
|
||||
const D3_JS = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
|
||||
const FA_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
|
||||
let d3Promise = null
|
||||
function ensureD3() {
|
||||
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
|
||||
if (!d3Promise) d3Promise = new Promise((res, rej) => {
|
||||
const s = document.createElement('script'); s.src = D3_JS
|
||||
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return d3Promise
|
||||
}
|
||||
// Font Awesome's @font-face only registers from the DOCUMENT; its classes must also be inside the
|
||||
// shadow root. So the stylesheet goes in both places.
|
||||
function ensureFontAwesome(root) {
|
||||
if (typeof document === 'undefined') return
|
||||
if (!document.head.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; document.head.appendChild(l)
|
||||
}
|
||||
if (root && root !== document && !root.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; root.prepend(l)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'WallOfPeopleGroups',
|
||||
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog, SourceSelector },
|
||||
data() {
|
||||
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
|
||||
},
|
||||
mounted() {
|
||||
ensureFontAwesome(this.$el?.getRootNode?.())
|
||||
ensureD3().then(() => { this.ready = true }, (e) => { this.error = e.message })
|
||||
},
|
||||
methods: {
|
||||
openCommentsDialog(group) { this.selectedGroup = group; this.commentsDialogVisible = true },
|
||||
closeCommentsDialog() {
|
||||
this.commentsDialogVisible = false
|
||||
setTimeout(() => { this.selectedGroup = null }, 300)
|
||||
},
|
||||
openPrayerDialog(group) { this.selectedGroup = group; this.prayerDialogVisible = true },
|
||||
closePrayerDialog() {
|
||||
this.prayerDialogVisible = false
|
||||
setTimeout(() => { if (!this.commentsDialogVisible) this.selectedGroup = null }, 300)
|
||||
},
|
||||
handlePrayerConfirmed() { /* PrayerDialog already updated the store */ },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<source-selector />
|
||||
<people-groups-wall v-if="ready" @open-comments="openCommentsDialog" @open-prayer="openPrayerDialog" />
|
||||
<div v-else-if="error" class="wall-error">The wall could not load its drawing library: {{ error }}</div>
|
||||
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />
|
||||
<prayer-dialog :is-open="prayerDialogVisible" :group="selectedGroup" @close="closePrayerDialog" @prayer-confirmed="handlePrayerConfirmed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="./src/wall.css"></style>
|
||||
<style>
|
||||
:host { display: block; position: relative; min-height: 600px; }
|
||||
.app-container { position: relative; width: 100%; height: 100%; }
|
||||
.wall-error { padding: 24px; color: #3b463d; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
</style>
|
||||
42
public/doxa-maps/doxa-maps-spa/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 34,
|
||||
"bundleCount": 23,
|
||||
"profileCount": 35,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
|
|
@ -493,7 +493,7 @@
|
|||
"bundle": "doxa-vision-card",
|
||||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
|
||||
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9",
|
||||
"integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
|
||||
"elements": [
|
||||
"doxa-vision-card"
|
||||
],
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/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=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -863,7 +863,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
|
||||
"integrity": "sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -879,7 +879,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1059,7 +1059,7 @@
|
|||
"bundle": "upg-neighbor",
|
||||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
|
||||
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
|
||||
"integrity": "sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
|
||||
"elements": [
|
||||
"upg-neighbor"
|
||||
],
|
||||
|
|
@ -1075,7 +1075,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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN\" 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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi\" 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": [
|
||||
{
|
||||
|
|
@ -1152,6 +1152,32 @@
|
|||
"bundle": "upg-neighbor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "wall-of-people-groups",
|
||||
"file": "wall-of-people-groups.vue",
|
||||
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||
"element": "wall-of-people-groups",
|
||||
"type": "monolithic",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
|
||||
<script src="../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-vision-card.js" integrity="sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
42
public/doxa-maps/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 34,
|
||||
"bundleCount": 23,
|
||||
"profileCount": 35,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
|
|
@ -493,7 +493,7 @@
|
|||
"bundle": "doxa-vision-card",
|
||||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
|
||||
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9",
|
||||
"integrity": "sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj",
|
||||
"elements": [
|
||||
"doxa-vision-card"
|
||||
],
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/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=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-ga7XpXFyxJDd8VrtMejKuLRx8YFuxJoT+DXkcpehJXptsM4phiKamyTBYJpudSSj\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -863,7 +863,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
|
||||
"integrity": "sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -879,7 +879,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1059,7 +1059,7 @@
|
|||
"bundle": "upg-neighbor",
|
||||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"output": "doxa-maps/upg-neighbor/upg-neighbor.js",
|
||||
"integrity": "sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN",
|
||||
"integrity": "sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi",
|
||||
"elements": [
|
||||
"upg-neighbor"
|
||||
],
|
||||
|
|
@ -1075,7 +1075,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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN\" 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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi\" 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": [
|
||||
{
|
||||
|
|
@ -1152,6 +1152,32 @@
|
|||
"bundle": "upg-neighbor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "wall-of-people-groups",
|
||||
"file": "wall-of-people-groups.vue",
|
||||
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||
"element": "wall-of-people-groups",
|
||||
"type": "monolithic",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY" crossorigin="anonymous"></script>
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-v0hrmAp17LGeHHfWbgdEkrTILIqrZRQh2QDFT1tgxN359vVW/Xt7t+GAQS16uA4U" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
86
public/doxa-maps/upg-neighbor/upg-neighbor.js
generated
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,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-YViVG3snYU3j6AZ8ZxBIJlBBMUL5Pj2guGdWiiKvexR9VNtQFCHrX+PQ6lLa2DrN" crossorigin="anonymous"></script>
|
||||
<script src="../../upg-neighbor.js" integrity="sha384-L9U5BmHdIQSgYXleGnKoycRFoPQq/zCBFGCK+76Xt++6f/Z0byB9sSlVef7ofjXi" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
192
public/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html
generated
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>wall-of-people-groups · wall-of-people-groups</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, wall-of-people-groups { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <wall-of-people-groups> custom element. -->
|
||||
<script src="../wall-of-people-groups.js" integrity="sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"wall-of-people-groups","dataSource":"pray-tools","instanceId":"wall-of-people-groups-wall-of-people-groups","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("wall-of-people-groups");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
if (NESTS_MAPS && !directToken) {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||