Merge remote-tracking branch 'origin/main' into rega/research-gallery-embed
# Conflicts: # public/doxa-maps/index.html
This commit is contained in:
commit
6356cc3876
19 changed files with 3633 additions and 7 deletions
97
internal/check-basemap-labels-localized.mjs
Normal file
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.`)
|
||||
168
internal/check-poster-wall-localised.mjs
Normal file
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.`)
|
||||
|
|
@ -125,6 +125,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",
|
||||
|
|
@ -201,7 +202,8 @@
|
|||
"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:poster-wall-localised": "node internal/check-poster-wall-localised.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.",
|
||||
|
|
@ -304,7 +306,8 @@
|
|||
"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: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."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
14
plugin-designs/wall-of-people-groups/index.html
Normal file
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
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
1162
plugin-designs/wall-of-people-groups/src/wall.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
30
public/doxa-maps/doxa-maps-spa/manifest.json
generated
30
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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
30
public/doxa-maps/manifest.json
generated
30
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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
File diff suppressed because one or more lines are too long
192
public/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html
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>
|
||||
Loading…
Reference in a new issue