Merge main into bundler-1/work (regenerate the tree, re-hash)
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
The one conflict was in public/doxa-maps/index.html, which generate-tree writes. Built files are regenerated, not hand-merged: country-posters was rebuilt because main changed its source (a new two-tab MFE), regions-dashboard because this branch did, then the tree and the hashes.
This commit is contained in:
commit
c1ab013f9e
11 changed files with 723 additions and 48 deletions
91
internal/check-mfe-two-tab.mjs
Normal file
91
internal/check-mfe-two-tab.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// check-mfe-two-tab — a two-tab MFE must actually MOUNT BOTH PANES and switch.
|
||||
//
|
||||
// The failure this catches is BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: every layer
|
||||
// inside these composites is position:absolute/height:100%, so the whole chain
|
||||
// resolves to ZERO unless :host is sized. The header still renders and the tab
|
||||
// strip still paints — the element looks alive and every pane is 0px tall. That is
|
||||
// the "elements are not embedding" report, and a DOM-presence check passes it.
|
||||
// So this asserts MEASURED pane height, not mere existence.
|
||||
//
|
||||
// It also asserts ONE share affordance (two on one surface shipped once as
|
||||
// BUG-UPG100-DOUBLE-SHARE) and that clicking tab 2 actually changes the pressed
|
||||
// tab — a strip that paints but does not switch is the other silent half.
|
||||
//
|
||||
// Usage: node internal/check-mfe-two-tab.mjs <url> [expectedTabCount]
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const URL = process.argv[2]
|
||||
if (!URL) { console.error('usage: check-mfe-two-tab.mjs <url> [tabs]'); process.exit(2) }
|
||||
const WANT_TABS = Number(process.argv[3] || 2)
|
||||
|
||||
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
const errs = []
|
||||
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
|
||||
await pg.goto(URL, { waitUntil: 'load', timeout: 45000 })
|
||||
await pg.waitForTimeout(9000)
|
||||
|
||||
// Everything lives in a shadow root, so reach through it explicitly.
|
||||
const r = await pg.evaluate(() => {
|
||||
// Find the host by SHADOW ROOT, not by rendered content: an element that
|
||||
// collapsed to 0px still HAS its shadow root, and reporting "not found" would
|
||||
// misdiagnose the zero-height collapse as a missing element. Measured — removing
|
||||
// :host produced exactly that misleading message.
|
||||
const host = [...document.querySelectorAll('*')].find((e) => e.shadowRoot
|
||||
&& (e.shadowRoot.querySelector('.viewsel, .pane-col, [role="tab"]')
|
||||
|| /mfe/i.test(e.tagName)))
|
||||
if (!host) return { error: 'no custom element with a shadow root found on this page' }
|
||||
const sr = host.shadowRoot
|
||||
// ⚠ Select the TABS, not their container. '.viewsel .seg' matches the tablist
|
||||
// WRAPPER as well as its buttons, which counted 3 tabs for a 2-tab strip and
|
||||
// concatenated their text ("Country PostersMap"). role=tab is the semantic
|
||||
// anchor ViewSelect already sets, so it cannot drift with the class names.
|
||||
const tabs = [...sr.querySelectorAll('[role="tab"]')]
|
||||
.map((t) => (t.textContent || '').trim()).filter(Boolean)
|
||||
const panes = [...sr.querySelectorAll('.pane')].map((p) => {
|
||||
const box = p.getBoundingClientRect()
|
||||
const mounted = !!p.querySelector('.pane-mount > *')
|
||||
return { h: Math.round(box.height), w: Math.round(box.width), mounted }
|
||||
})
|
||||
const shares = sr.querySelectorAll('[aria-label*="hare"], .share-btn, .sharebtn').length
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
return { tabs, panes, shares, hostH: Math.round(hostBox.height) }
|
||||
})
|
||||
await b.close()
|
||||
|
||||
if (r.error) { console.error(`MFE-TWO-TAB: FAIL — ${r.error}`); process.exit(1) }
|
||||
|
||||
const fails = []
|
||||
if (r.tabs.length !== WANT_TABS) fails.push(`${r.tabs.length} tab(s) in the strip, expected ${WANT_TABS}`)
|
||||
// ★ THE HOST HEIGHT IS THE LOAD-BEARING ASSERTION, not the pane heights.
|
||||
// MEASURED: squashing the host to 0px leaves its panes still reporting 843px,
|
||||
// because they are position:absolute and escape the collapsed box. So a
|
||||
// pane-height check alone does NOT catch BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT —
|
||||
// the panes look fine while the element occupies no space and the reader sees
|
||||
// nothing. Only the host measurement reveals it.
|
||||
if (r.hostH < 100) {
|
||||
fails.push(`the host element measured ${r.hostH}px tall — :host is not sized, so the `
|
||||
+ 'element occupies no space even though its panes still measure normally')
|
||||
}
|
||||
if (r.panes.length !== WANT_TABS) fails.push(`${r.panes.length} pane(s) mounted, expected ${WANT_TABS}`)
|
||||
for (const [i, p] of r.panes.entries()) {
|
||||
if (!p.mounted) fails.push(`pane ${i} has no child element mounted`)
|
||||
// Kept as a secondary signal only — see the note on hostH above for why this
|
||||
// one cannot be trusted to catch the collapse on its own.
|
||||
if (p.h < 100) fails.push(`pane ${i} measured ${p.h}px tall`)
|
||||
}
|
||||
if (r.shares > 1) fails.push(`${r.shares} share affordances on one surface — exactly one belongs here`)
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
|
||||
console.log(` host height : ${r.hostH}px`)
|
||||
console.log(` tabs : ${r.tabs.join(' | ') || '(none)'}`)
|
||||
console.log(` panes : ${r.panes.map((p) => `${p.w}x${p.h}${p.mounted ? '' : ' UNMOUNTED'}`).join(', ')}`)
|
||||
console.log(` share buttons : ${r.shares}`)
|
||||
console.log(` page errors : ${errs.length}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nMFE-TWO-TAB: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nMFE-TWO-TAB: PASS — ${WANT_TABS} tabs, both panes mounted and sized, one share affordance.`)
|
||||
99
internal/check-provider-switch.mjs
Normal file
99
internal/check-provider-switch.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// check-provider-switch — ?provider=maplibre must actually SWAP THE ENGINE.
|
||||
//
|
||||
// The bug this gates (BUG-UPG100-IGNORES-THE-PROVIDER-SWITCH): my-upg-100-list
|
||||
// builds its map RAW with `new gl.Map` instead of going through useMapInstance, so
|
||||
// the page-level switch — which resolves inside that composable — could not reach
|
||||
// it. MEASURED at the time: five sibling leaves swapped cleanly while this one
|
||||
// stayed on Mapbox and made 19 requests to api.mapbox.com.
|
||||
//
|
||||
// ★ IT COUNTS NETWORK REQUESTS BY HOST, not globals. `window.mapboxgl` can be
|
||||
// present because a page preloads it, and a map can still be DRAWN by maplibre —
|
||||
// or the reverse. What the switch is for is not loading a proprietary engine and
|
||||
// not calling its tile servers, and only the request log shows that.
|
||||
//
|
||||
// Usage: node internal/check-provider-switch.mjs <leafUrl>
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
// ⚠ NOT named `URL` — that shadows the global URL constructor, so every
|
||||
// `new URL(request.url())` below throws and the host tally stays EMPTY while the
|
||||
// gate happily reports 0 mapbox requests and PASSES. Measured: it did exactly
|
||||
// that, hiding 4 real requests to events.mapbox.com.
|
||||
const LEAF = process.argv[2]
|
||||
if (!LEAF) { console.error('usage: check-provider-switch.mjs <leafUrl>'); process.exit(2) }
|
||||
|
||||
async function run(provider) {
|
||||
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
const hosts = new Map()
|
||||
let unparsed = 0
|
||||
pg.on('request', (r) => {
|
||||
try {
|
||||
const h = new URL(r.url()).hostname
|
||||
if (!h) { unparsed += 1; return }
|
||||
hosts.set(h, (hosts.get(h) || 0) + 1)
|
||||
} catch { unparsed += 1 }
|
||||
})
|
||||
const errs = []
|
||||
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
const url = provider ? `${LEAF}${LEAF.includes('?') ? '&' : '?'}provider=${provider}` : LEAF
|
||||
await pg.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||||
await pg.waitForTimeout(12000)
|
||||
const globals = await pg.evaluate(() => ({
|
||||
mapbox: typeof window.mapboxgl !== 'undefined',
|
||||
maplibre: typeof window.maplibregl !== 'undefined',
|
||||
}))
|
||||
await b.close()
|
||||
const count = (needle) => [...hosts.entries()]
|
||||
.filter(([h]) => h.includes(needle)).reduce((a, [, n]) => a + n, 0)
|
||||
// Report what could NOT be parsed rather than swallowing it: a silently empty
|
||||
// tally is how this gate first passed while measuring nothing at all.
|
||||
return { mapboxReq: count('mapbox.com'), globals, errs, unparsed, total: [...hosts.values()].reduce((a, n) => a + n, 0) }
|
||||
}
|
||||
|
||||
const def = await run(null)
|
||||
const ml = await run('maplibre')
|
||||
|
||||
// DERIVED from the leaf's own HTML, not a hardcoded number: count the distinct
|
||||
// api.mapbox.com references the generated page carries, then allow a little slack
|
||||
// because a stylesheet can be fetched more than once (the upg-100 leaf resolves 4
|
||||
// distinct urls but issues 5 requests — one css is re-fetched). Hardcoding 5 would
|
||||
// pass a leaf that legitimately preloads 4 and leaks 1 runtime call.
|
||||
const pageHtml = await (await fetch(LEAF)).text()
|
||||
const declared = new Set((pageHtml.match(/https:\/\/api\.mapbox\.com[^"')]+/g) || []))
|
||||
const PRELOAD_ALLOWANCE = Number(process.env.PROVIDER_PRELOADS ?? (declared.size + 1))
|
||||
|
||||
console.log(` page preloads : ${declared.size} distinct api.mapbox.com url(s) in the leaf HTML`)
|
||||
console.log(` default : ${def.mapboxReq} mapbox / ${def.total} total request(s) `
|
||||
+ `[mapboxgl=${def.globals.mapbox} maplibregl=${def.globals.maplibre}]`)
|
||||
console.log(` ?provider=maplibre: ${ml.mapboxReq} mapbox / ${ml.total} total request(s) `
|
||||
+ `[mapboxgl=${ml.globals.mapbox} maplibregl=${ml.globals.maplibre}]`)
|
||||
|
||||
const fails = []
|
||||
if (!ml.globals.maplibre) {
|
||||
fails.push('?provider=maplibre did not load the maplibre engine at all')
|
||||
}
|
||||
// The RESIDUAL SET is the leaf page's own <link>/<script> preloads — mapbox-gl and
|
||||
// the geocoder, referenced in the generated index.html header. Those fire before any
|
||||
// Vue code runs, so no runtime switch can suppress them; removing them is a separate
|
||||
// concern (the page template), not this bug. What the switch must achieve is that NO
|
||||
// RUNTIME request goes to Mapbox: measured 19 -> 5 on the upg-100 leaf, and all 5 are
|
||||
// in the HTML. Asserting "fewer than default" would pass a half-fix at 18.
|
||||
if (ml.mapboxReq > PRELOAD_ALLOWANCE) {
|
||||
fails.push(`?provider=maplibre made ${ml.mapboxReq} mapbox request(s), more than the `
|
||||
+ `${PRELOAD_ALLOWANCE} page preloads — ${ml.mapboxReq - PRELOAD_ALLOWANCE} are RUNTIME `
|
||||
+ 'calls the switch failed to redirect')
|
||||
}
|
||||
if (def.mapboxReq <= PRELOAD_ALLOWANCE) {
|
||||
fails.push(`the default run made only ${def.mapboxReq} mapbox request(s) — the map is probably `
|
||||
+ 'not rendering, so this comparison proves nothing')
|
||||
}
|
||||
if (!def.total) fails.push('no requests were recorded at all — the tally is broken, not the page')
|
||||
if (ml.errs.length) fails.push(`${ml.errs.length} page error(s) under maplibre: ${ml.errs[0]}`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nPROVIDER-SWITCH: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPROVIDER-SWITCH: PASS — maplibre loads and mapbox traffic drops `
|
||||
+ `${def.mapboxReq} -> ${ml.mapboxReq}.`)
|
||||
|
|
@ -57,6 +57,8 @@
|
|||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:card-deck": "node internal/check-card-deck.mjs",
|
||||
"check:poster-raster": "node internal/check-poster-raster.mjs",
|
||||
"check:provider-switch": "node internal/check-provider-switch.mjs",
|
||||
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
|
||||
"check:share-policy": "node internal/check-share-policy.mjs",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs",
|
||||
"check:print-geometry": "node internal/check-print-geometry.mjs",
|
||||
|
|
|
|||
12
plugin-designs/country-posters/country-posters-mfe.meta.json
Normal file
12
plugin-designs/country-posters/country-posters-mfe.meta.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"type": "nested-elements-mfe",
|
||||
"label": "Country Posters — Wall + Map",
|
||||
"composes": [
|
||||
"country-posters/country-poster-wall",
|
||||
"country-posters/countries-map"
|
||||
],
|
||||
"note": "The Driver's 2-tab Country Posters element, structured like my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (the SHARED library/components/ViewSelect.vue — this is its THIRD consumer, which is why it was extracted rather than copied a third time) over Country Posters at [0,0] and Map at [1,0]. LIST FIRST, MAP SECOND per the Driver's principle that maps are a secondary view. The wall already dispatches 'doxa-country-posters:show-on-map' (country-poster-wall.vue:78); until now only the app shell listened, so the action did nothing in a dropped embed — this shell owns it and swaps to the map tab. pinHint:false: the cloned panes carry no coach-mark, and the composite must declare it because a pane's config comes from here, not from the child's own meta. One ShareButton in the header, sharing the COMPOSITE leaf, per the recorded share policy.",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
}
|
||||
}
|
||||
200
plugin-designs/country-posters/country-posters-mfe.vue
Normal file
200
plugin-designs/country-posters/country-posters-mfe.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script setup>
|
||||
/**
|
||||
* country-posters-mfe.vue — Country Posters as a TWO-TAB element.
|
||||
*
|
||||
* Driver: "Country Posters is supposed to have two tabs, structured like the
|
||||
* UPG-100 list."
|
||||
*
|
||||
* ★ THE CONTROL IS SHARED, NOT COPIED. That is the whole point of the card: this
|
||||
* is the THIRD place the SELECT VIEW switcher is needed, and it uses the library's
|
||||
* ViewSelect.vue — the same component upg-100-mfe and regions-mfe render. A third
|
||||
* hand-rolled copy is exactly what library/components/ViewSelect.vue was extracted
|
||||
* to prevent (its own header records that a fourth divergent copy is not what the
|
||||
* library is for).
|
||||
*
|
||||
* SAME SHAPE AS upg-100-mfe, deliberately — index.js already says this bundle is
|
||||
* "the same shape as my-upg-100-list", and copying its structure is what makes the
|
||||
* two elements behave identically for a reader who moves between them:
|
||||
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES)
|
||||
* • each pane keeps its own ProfileLoader/Pinia
|
||||
* • one ShareButton in the header, per the recorded share policy
|
||||
* (check:share-policy) — the MAP pane must not grow a second one
|
||||
*
|
||||
* LIST FIRST, MAP SECOND. The Driver's principle, already applied in upg-100-mfe:
|
||||
* "maps don't have to be primary. You can use lists and grids for the primary view
|
||||
* and maps as a secondary view… then they transition into the map." The poster wall
|
||||
* is the concrete artefact a newcomer can act on; the map is where it is placed.
|
||||
*/
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ViewSelect from '@map/components/ViewSelect.vue'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
||||
const cfg = inject('profileConfig', ref(null))
|
||||
const uiStore = inject('uiStore', null)
|
||||
const isDark = computed(() => !!uiStore?.isDark)
|
||||
|
||||
const TAG = 'country-posters-map'
|
||||
// One instanceId shared by BOTH panes, so a scoped cross-pane event addresses THIS
|
||||
// embed even when two Country Posters elements sit on the same page.
|
||||
const instanceId = computed(() => cfg.value?.instanceId || 'country-posters-mfe')
|
||||
// The map pane needs the Mapbox token the host page was given; the wall does not.
|
||||
const tk = computed(() => cfg.value?.tk || '')
|
||||
|
||||
const spec = computed(() => ({
|
||||
id: 'country-posters-mfe',
|
||||
tag: TAG,
|
||||
// pinHint:false reaches the PANES from here, not from their own meta. A child's
|
||||
// meta configures only its own LEAF page; nested in this composite each pane is
|
||||
// handed the config built below, and PinHint.vue reads pinHint off the INJECTED
|
||||
// profileConfig — so without this the coach-mark reappears over the cloned map.
|
||||
config: { profile: 'country-poster-wall', instanceId: instanceId.value, pinHint: false },
|
||||
// ORDER LIVES IN at[0], NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by at[0]
|
||||
// while ViewSelect renders this array VERBATIM, so the two must agree or the tab
|
||||
// strip desynchronises from the scroll column.
|
||||
panes: [
|
||||
{ id: 'wall', label: 'Country Posters', at: [0, 0], config: { profile: 'country-poster-wall' } },
|
||||
{ id: 'map', label: 'Map', at: [1, 0], config: { profile: 'countries-map' } },
|
||||
],
|
||||
// The switcher lives in this element's own header, so the layout's "… below ↓"
|
||||
// pill would be a second, redundant control (same call upg-100-mfe makes).
|
||||
paneHint: false,
|
||||
}))
|
||||
|
||||
const stage = ref(null)
|
||||
// DERIVED from the pane order, never a hardcoded id — the same trap upg-100-mfe
|
||||
// records: `ref('map')` silently became wrong the moment the panes were reordered.
|
||||
const firstPaneId = computed(() => {
|
||||
const col = spec.value.panes
|
||||
.filter((p) => (p.at?.[1] ?? 0) === 0)
|
||||
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
|
||||
return col[0]?.id || 'wall'
|
||||
})
|
||||
const viewRaw = ref(null)
|
||||
const view = computed({
|
||||
get: () => viewRaw.value || firstPaneId.value,
|
||||
set: (v) => { viewRaw.value = v },
|
||||
})
|
||||
let column = null
|
||||
let offScroll = null
|
||||
|
||||
// PAGE-LEVEL config keys a pane INHERITS from the host page. A cap-4 composite
|
||||
// nests children as custom elements in THIS document, so anything the generator
|
||||
// bakes once per page (where assets are served from, which backend, the theme) has
|
||||
// to be forwarded explicitly or the child falls back to its own default — the
|
||||
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S failure, where every preview 404'd because
|
||||
// assetsBase never reached the consumer.
|
||||
// Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
|
||||
// `profile`/`instanceId` cannot be overwritten (pane.config is spread last).
|
||||
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa']
|
||||
|
||||
function paneElement(pane) {
|
||||
const el = document.createElement(TAG)
|
||||
const host = cfg.value || {}
|
||||
const inherited = {}
|
||||
for (const k of INHERITED_PAGE_KEYS) {
|
||||
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
|
||||
}
|
||||
// `lang` is the alias the page may carry; children read `locale`.
|
||||
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
|
||||
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
|
||||
if (tk.value) pcfg.tk = tk.value
|
||||
el.setAttribute('profile-config', JSON.stringify(pcfg))
|
||||
return el
|
||||
}
|
||||
|
||||
function go(id) {
|
||||
view.value = id
|
||||
column?.scrollTo(id)
|
||||
}
|
||||
|
||||
// ── tab 1 -> tab 2 (shell seam) ─────────────────────────────────────────────
|
||||
// country-poster-wall already dispatches this scoped window event when a card's
|
||||
// "Show on map" is clicked — country-poster-wall.vue:78 sets event-name. Until now
|
||||
// ONLY the app shell listened (app-wrapper-material/index.html), so the action did
|
||||
// nothing in a dropped embed or a nested composite: the event fired into a document
|
||||
// with no handler. Owning it here is what makes the two tabs one element rather
|
||||
// than two pages that happen to share a bundle.
|
||||
const SHOW_ON_MAP = 'doxa-country-posters:show-on-map'
|
||||
function onShowOnMap(e) {
|
||||
const d = e?.detail || {}
|
||||
if (d.instanceId && d.instanceId !== instanceId.value) return
|
||||
go('map')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!stage.value) return
|
||||
column = buildPaneColumn(spec.value, { host: stage.value })
|
||||
if (!column) return
|
||||
for (const pane of column.panes) {
|
||||
if (!pane.config) continue
|
||||
column.mountEl(pane.id)?.appendChild(paneElement(pane))
|
||||
}
|
||||
// Tabs are a readout as well as a control — follow the user's scroll.
|
||||
offScroll = column.onScroll(() => {
|
||||
const cur = column.current()
|
||||
if (cur && cur !== view.value) view.value = cur
|
||||
})
|
||||
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(SHOW_ON_MAP, onShowOnMap)
|
||||
offScroll?.()
|
||||
column?.destroy()
|
||||
column = null
|
||||
})
|
||||
|
||||
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE
|
||||
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root.
|
||||
// ★ Sharing the COMPOSITE, not a child, is the BUG-SHARE-FROM-CAP4-COMPOSITE bug
|
||||
// this pattern already paid for once.
|
||||
const SHARE_EMBED_PATH = 'country-posters/country-posters-mfe/index.html'
|
||||
|
||||
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
|
||||
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
|
||||
useShadowStyles(PANE_GRID_STYLES + `
|
||||
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
|
||||
position:absolute / height:100%, so the whole chain resolves to ZERO unless the HOST is
|
||||
sized. A standalone leaf page papers over it with a page-level rule; inside a tab layout
|
||||
no such rule exists and every pane collapses to 0 — the "elements are not embedding"
|
||||
report. Declaring :host makes the element fill its slot in every deploy method. */
|
||||
:host{ display:block; width:100%; height:100%; min-height:420px; }
|
||||
/* ── palette: DX-life material light. NO AUTO-DARK (Driver 2026-09-20): a system set to
|
||||
dark mode must not flip this element. ── */
|
||||
.cpmfe{
|
||||
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
|
||||
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
|
||||
--header:56px;
|
||||
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
|
||||
background:var(--ui-bg); color:var(--ui-text);
|
||||
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
|
||||
}
|
||||
.cpmfe .hdr{
|
||||
flex:0 0 var(--header); height:var(--header); position:relative;
|
||||
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
|
||||
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
|
||||
}
|
||||
/* Only the PLACEMENT lives here — the control's metrics are the shared component's. */
|
||||
.viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
|
||||
.cpmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
|
||||
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
|
||||
`, 'country-posters-mfe')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cpmfe">
|
||||
<header class="hdr">
|
||||
<ViewSelect
|
||||
:views="spec.panes"
|
||||
:current="view"
|
||||
legend="Select View"
|
||||
label="Views"
|
||||
@select="go"
|
||||
/>
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
|
||||
</header>
|
||||
<div class="stage" ref="stage"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
182
public/doxa-maps/country-posters/country-posters-mfe/index.html
generated
Normal file
182
public/doxa-maps/country-posters/country-posters-mfe/index.html
generated
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>country-posters-mfe · country-posters</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<style>
|
||||
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
|
||||
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
|
||||
scrolls PAST one stacked section down to the next (BUG-DASHBOARD-CANT-SCROLL-DOWN). This is the
|
||||
OPPOSITE of the fixed-viewport map/single-element leaf below: NO position:fixed, NO inset:0,
|
||||
height:auto so the page can grow. See StackLayout.vue's "THE LAYOUT OWNS THE SCROLL" contract. */
|
||||
html { height: 100%; }
|
||||
html, body { margin: 0; background: #0a0e15; }
|
||||
body { min-height: 100%; overflow-y: auto; }
|
||||
/* MIN-HEIGHT IN VIEWPORT UNITS, NOT PERCENT (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT).
|
||||
min-height:100% here resolved against BODY's computed height, which is auto (body only has
|
||||
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
|
||||
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
|
||||
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page
|
||||
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */
|
||||
#slot, country-posters-map { position: static; inset: auto; width: 100%; min-height: 100dvh; 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 token…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7" 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 = {"pinHint":false,"profile":"country-posters-mfe","dataSource":"pray-tools","instanceId":"country-posters-country-posters-mfe","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 = 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. The Driver is
|
||||
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
|
||||
a per-frame or per-event handler. */
|
||||
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(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("country-posters-map");
|
||||
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) { return mount(''); }
|
||||
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>
|
||||
153
public/doxa-maps/country-posters/country-posters.js
generated
153
public/doxa-maps/country-posters/country-posters.js
generated
File diff suppressed because one or more lines are too long
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
26
public/doxa-maps/manifest.json
generated
26
public/doxa-maps/manifest.json
generated
|
|
@ -3,7 +3,7 @@
|
|||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 33,
|
||||
"profileCount": 34,
|
||||
"bundles": [
|
||||
{
|
||||
"bundle": "all-people-groups",
|
||||
|
|
@ -154,10 +154,14 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "doxa-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB",
|
||||
"integrity": "sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
"composes": [
|
||||
"country-posters/country-poster-wall",
|
||||
"country-posters/countries-map"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "countries-map",
|
||||
|
|
@ -170,7 +174,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/countries-map/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=\"./country-posters/country-posters.js\" integrity=\"sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -184,7 +188,21 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-uGk+BENGktebtR4QYc630qqtM8kDPK5uxPo6mwoWMqD/CTrala4WfAA3hNyg9ZuB\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile": "country-posters-mfe",
|
||||
"file": "country-posters-mfe.vue",
|
||||
"description": "",
|
||||
"element": "country-posters-map",
|
||||
"type": "nested-elements-mfe",
|
||||
"isMap": true,
|
||||
"iframeUrl": "./country-posters/country-posters-mfe/index.html",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/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=\"./country-posters/country-posters.js\" integrity=\"sha384-QdkwinBM2mJMqVlEX7HYctnr/9k9Nj01MDwFGwYL7tT62O8Nm3JuujLJ8qxZTFJ7\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue