doxa-maps/library/ProfileLoader.vue
doxa-maps 1cd2b46cd5
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
fix(spa): make embedded maps survive the sandboxed plugin iframe
Three fixes for the power-app SPA running as a go-base plugin (sandboxed
iframe, origin 'null' — where localStorage throws and root-absolute asset
paths misresolve under the /plugin-assets/<name>/ subpath):

- localStorage: wrap every theme/preference read+write in try/catch across
  the library (ProfileLoader, useMapTheme, uiStore). `typeof localStorage`
  is not enough — in a sandbox the object exists but ACCESS throws
  SecurityError. Sandboxed maps now boot themeless instead of crashing.
- assets: the SPA shell now injects an absolute assetsBase (derived from its
  own document.baseURI) into every mounted element's profile-config, so
  bundles fetch tables/vendor (Tabulator) from the correct subpath instead
  of a root-absolute /assets that 404s under /plugin-assets/doxa-maps-spa/.
- nav: revert the Prayer Mobilization section icon to 'heart' (the prayer
  glyph read as a header it is not).

Rebuilds every bundle (library-wide localStorage change).
2026-09-12 21:07:26 +00:00

192 lines
9.4 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* ProfileLoader.vue — Application Profile Loader
*
* The ONLY component that reads the profile-config prop.
* Parses it → resolves the profile name → dynamically loads the profile .vue file.
* Everything else in the app is prop-free at the web component boundary.
*
* Usage:
* <doxa-map profile-config='{"profile":"example-map","tk":"pk.eyJ...","instanceId":"my-map"}'></doxa-map>
*
* instanceId — optional. If omitted, a random ID is generated.
* Used to isolate window events so multiple <doxa-map> embeds on the same page
* never interfere with each other.
*/
import { computed, defineAsyncComponent, provide, inject, watchEffect } from 'vue'
import { SUPPORTED_LOCALES } from './i18n/index.js'
import { useUIStore } from './stores/uiStore.js'
import { useMapStore } from './stores/mapStore.js'
import { useDataStore } from './stores/dataStore.js'
import sourcesConfig from './api/sources.json'
// ─── Props ───────────────────────────────────────────────────────────────────
const props = defineProps({
/**
* JSON string passed as an HTML attribute.
* Shape: { profile, tk, instanceId?, dataSource?, tabs?, colorSet? }
*/
profileConfig: {
type: String,
default: ''
}
})
// ─── Parse profile-config ─────────────────────────────────────────────────────
const config = computed(() => {
if (!props.profileConfig) return null
try {
return JSON.parse(props.profileConfig)
} catch (e) {
console.error('[ProfileLoader] Invalid profile-config JSON:', props.profileConfig)
return null
}
})
const profileName = computed(() => config.value?.profile || null)
const mapboxToken = computed(() => config.value?.tk || '')
// Default to the CONFIGURED active source from sources.json (e.g. 'pray-tools', the live API)
// — NOT a hardcoded 'doxa-csv'. A host/gem-frame that embeds the bundle without an explicit
// profile-config.dataSource (the platform case) was silently falling back to 'doxa-csv', whose
// RELATIVE CSV path 404s off the CDN origin → the map loaded but NO data fetch fired (looked
// like an "API access" problem; the API is actually CORS-open). Honoring sources.json's
// activeSource makes the bundle render live data by default on any host, while still letting a
// host override via profile-config.dataSource. (doxa-csv stays the final offline fallback.)
const dataSource = computed(() => config.value?.dataSource || sourcesConfig.activeSource || 'doxa-csv')
const colorSet = computed(() => config.value?.colorSet || 'default')
// ─── Per-map language override (single keystone for ALL profiles) ─────────────
// `profile-config.locale` is the established contract — the host Nuxt page passes
// its @nuxtjs/i18n locale this way (see research-map.vue), used for both UI chrome
// and API `lang`. `lang` is accepted as an alias (the loc-002 card's wording).
// Setting it HERE activates every existing t() string per-map — geocoder, tabs,
// legend, search — for every profile, not just research-map. Validated against the
// shipped catalogs; an unknown/absent value leaves the i18n-detected default
// (document.documentElement.lang → 'en') untouched.
const lang = computed(() => {
const raw = (config.value?.locale || config.value?.lang || '').split('-')[0].toLowerCase()
return SUPPORTED_LOCALES.includes(raw) ? raw : ''
})
// CRITICAL: this component is the ROOT of a defineCustomElement. vue-i18n's
// useI18n() CANNOT resolve its injection at the custom-element root — it throws
// "Need to install with `provide` function" and the entire map fails to mount
// (blank gem frames on staging). This is true even though plain inject() works
// at the root (profileModules below resolves fine). So instead of useI18n(), we
// take the i18n INSTANCE the bundle entry installed — handed to us by reference
// via provide('appI18n') — and drive its global locale directly. Descendant
// components (geocoder, legend, tabs, LanguageSelector) are NOT the CE root, so
// they keep using useI18n() normally. See docs/DIAGNOSIS-emergency-staging-fix.md.
const appI18n = inject('appI18n', null)
watchEffect(() => { if (lang.value && appI18n) appI18n.global.locale.value = lang.value })
// Effective locale for non-i18n descendants (the geocoder reads useI18n directly).
const effectiveLocale = computed(() => lang.value || appI18n?.global?.locale?.value || 'en')
// ─── Instance ID ─────────────────────────────────────────────────────────────
// Optional in profile-config: { "instanceId": "my-map" }
// If not provided, a random ID is generated (stable for the element's lifetime).
// This ID scopes all cross-instance events so multiple <doxa-map> embeds never cross-talk.
const instanceId = computed(() =>
config.value?.instanceId || ('doxa-map-' + Math.random().toString(36).slice(2, 9))
)
// ─── Profile Registry — provided by bundle entry ────────────────────────────
// The bundle entry (app-profiles/<bundle>/index.js) MUST run
// app.provide('profileModules', import.meta.glob('./*.vue'))
// because import.meta.glob cannot cross package boundaries — it must be
// evaluated by the bundle that owns the profile .vue files.
const profileModules = inject('profileModules', null)
if (!profileModules) {
throw new Error('[ProfileLoader] No profileModules provided. Bundle entry must `app.provide("profileModules", import.meta.glob("./*.vue"))`')
}
// ─── Async component — loads the resolved profile file ───────────────────────
const ProfileComponent = computed(() => {
if (!profileName.value) return null
const key = `./${profileName.value}.vue`
if (!profileModules[key]) {
console.error(`[ProfileLoader] Profile not found: "${profileName.value}". Available:`, Object.keys(profileModules))
return null
}
return defineAsyncComponent(profileModules[key])
})
// ─── Provide parsed config to all descendants ────────────────────────────────
// Profile components and composables read these via inject() — no prop-drilling
provide('mapboxToken', mapboxToken)
provide('dataSource', dataSource)
provide('colorSet', colorSet)
provide('profileConfig', config)
provide('instanceId', instanceId)
provide('lang', effectiveLocale)
// ─── Instance-scoped Pinia stores ────────────────────────────────────────────
// ProfileLoader runs inside the correct Vue app (each <doxa-map> gets its own
// createPinia() via entry.js configureApp). Calling useXxxStore() HERE resolves
// to THIS app's Pinia instance. We provide the store instances so all descendants
// can inject them — no direct useXxxStore() calls in children, eliminating
// getActivePinia() cross-bleed when multiple <doxa-map> elements share a page.
const uiStore = useUIStore()
const mapStore = useMapStore()
const dataStore = useDataStore()
// Restore the persisted theme HERE — synchronously in setup, before any profile
// renders or creates its map. This is the SHARED boot so every profile is fixed
// at once (not each remembering to call uiStore.init() in onMounted). Without it,
// a profile that doesn't call init() (e.g. doxa-simple-map) renders LIGHT while
// the map's bootStyle() reads localStorage 'dark' directly → UI/map theme desync
// on reload. Doing it pre-render also kills the brief light→dark flash on the
// profiles that DO hydrate later in onMounted. init() (onMounted) still runs for
// the ResizeObserver / matchMedia listeners; setTheme is idempotent so the re-apply
// is harmless. (staging bug, coder 2026-06-25: dark selected + reload → map dark, UI light.)
// Sandboxed iframe (no allow-same-origin, origin 'null') → localStorage EXISTS but THROWS
// SecurityError on access, so a bare typeof-guard isn't enough. try/catch → skip persisted theme.
try {
const savedTheme = (typeof localStorage !== 'undefined') ? localStorage.getItem('theme') : null
if (savedTheme) uiStore.setTheme?.(savedTheme)
} catch (e) { /* sandboxed: no persisted theme */ }
provide('uiStore', uiStore)
provide('mapStore', mapStore)
provide('dataStore', dataStore)
</script>
<template>
<div class="profile-loader">
<!-- Error: no profile-config prop -->
<div v-if="!config" class="profile-error">
<p>⚠️ <strong>doxa-map</strong>: missing or invalid <code>profile-config</code> prop.</p>
<p>Example: <code>profile-config='{"profile":"example-map","tk":"pk.eyJ..."}'</code></p>
</div>
<!-- Error: profile name not found in registry -->
<div v-else-if="!ProfileComponent" class="profile-error">
<p>⚠️ Profile <strong>"{{ profileName }}"</strong> not found.</p>
</div>
<!-- Render the matched profile -->
<component :is="ProfileComponent" v-else />
</div>
</template>
<style scoped>
.profile-loader {
width: 100%;
height: 100%;
position: relative;
}
.profile-error {
padding: 1rem;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
</style>