my-upg-100-list renders from the shared PosterWall — 890 lines to 174
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
The original wall is gone: 716 lines of duplicated markup, CSS, export modal and print wiring deleted. This profile now supplies only what cannot be shared — REGION_OF/regionFor (its taxonomy, keyed by list id because a selector can be a wagf_block, a country_codes array or pg_slugs and none of those implies a continent) and fetchListRecords (the people-groups API, the selector evaluator and the pg_slugs special case). It joins country-posters (94 lines) and key-cities (75) on one component, which is what the Driver asked for: "don't recreate the wheel but imitate upg 100 list". It took three capability additions across three ticks, because migrating without them would have REGRESSED this wall rather than consolidated it: cardStocks, then posterAction="print" and hidePrayerListWhenSoon — the last two a Driver ruling, not a preference. ★ TWO REAL BUGS, BOTH INVISIBLE AT THE SOURCE LAYER, BOTH BUILDING CLEANLY. 1. groupBy SIGNATURE MISMATCH. regionFor takes an ID; the shared wall's groupBy takes the whole ITEM. `:group-by="regionFor"` compiled, the page loaded, and then threw `c.startsWith is not a function` at first render — the whole wall blank, zero cards, nothing wrong in the source. Adapted in the template rather than changing regionFor, since the id is what the taxonomy is keyed by. 2. A THROWN FETCH IS A MISS, NOT AN ERROR — and this one is a robustness gap in the SHARED component, affecting every consumer. fetch REJECTS rather than returning !ok when a request is blocked or offline, and exportPoster let that reject escape to the outer catch, skipping the rasterise fallback and showing "Failed to fetch" on a poster the browser could have generated from posters/<id>.html. The wall this component was EXTRACTED FROM already guarded it; the extraction did not carry the guard across. check:poster-print-fallback went PASS -> FAIL with 0 .html requests, and is green again. VERIFIED AT THE RENDERED LAYER, all three print actions on the migrated wall: 27 cards, 27 stock pickers, 4 buttons per card — the original's three prints plus show-on-map. CARD DECK 116 cards / 116 QR codes / @page 2.5in x 3.5in / 0 errors, identical to pre-migration. POSTER PRINT with every .webp aborted: 1 .html fetched, "generating the poster", clean finish. PRAYER LIST: 1 people-groups fetch, 0 errors — positive evidence the provider ran, since that print cannot work without it. REGRESSION CHECK: country-posters still downloads (0 print frames, 1 download), so the default is unchanged for the walls already on this component.
This commit is contained in:
parent
626603ca3f
commit
649b0872eb
18 changed files with 256 additions and 956 deletions
|
|
@ -217,8 +217,19 @@ async function exportPoster(item) {
|
|||
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch',
|
||||
message: 'fetching poster', pct: 20 }
|
||||
try {
|
||||
let res = await fetch(`${base.value}/posters/${item.id}.webp`)
|
||||
if (!res.ok) {
|
||||
// ⚠ A THROWN FETCH IS A MISS, NOT AN ERROR. `fetch` rejects — it does not return
|
||||
// !ok — when the request is blocked, offline, or refused by the network layer.
|
||||
// Letting that reject escape to the outer catch skips the rasterise fallback
|
||||
// entirely and reports "Failed to fetch" to the reader, on a poster the browser
|
||||
// could have generated from posters/<id>.html. Measured 2026-09-24 while
|
||||
// migrating my-upg-100-list onto this component: check:poster-print-fallback,
|
||||
// which aborts every .webp at the network layer to force the default-drop
|
||||
// condition, went from PASS on the old wall to FAIL here with 0 .html requests.
|
||||
// The wall this component was extracted from already guarded it; the extraction
|
||||
// did not carry the guard across.
|
||||
let res = null
|
||||
try { res = await fetch(`${base.value}/posters/${item.id}.webp`) } catch { /* treated as a miss */ }
|
||||
if (!res || !res.ok) {
|
||||
// ★ NO PRE-RENDERED IMAGE? GENERATE IT. The 378 .webp posters were 41 MB in
|
||||
// every clone, shipped to every reader whether or not they opened one — and a
|
||||
// publish was already rejected once at a 74 MB asset tree. The poster HTML is
|
||||
|
|
|
|||
|
|
@ -2,90 +2,62 @@
|
|||
/**
|
||||
* poster-wall.vue — the UPG-100 POSTER WALL (pane 2 of the my-upg-100-list family).
|
||||
*
|
||||
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map" action that
|
||||
* hands off to the map profile in the same bundle.
|
||||
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map"
|
||||
* action that hands off to the map profile in the same bundle.
|
||||
*
|
||||
* A grid of poster cards, one per 100-list, styled as posters taped to a wall:
|
||||
* HEADER (tape) — a rounded "Show on map" button, NARROWER than the card and
|
||||
* overlapping the top edge like a strip of tape. Click emits UP
|
||||
* to the shell, which relays it to the MAP pane.
|
||||
* MAIN — the poster preview image in a thin-bordered card.
|
||||
* FOOTER (tape) — a boxy strip, also narrower + overlapping the bottom edge:
|
||||
* Export poster · Prayer packet.
|
||||
* ★ MIGRATED ONTO THE SHARED WALL 2026-09-24. This file was 890 lines carrying its own copy of
|
||||
* markup, CSS, the export modal and three print paths; the rendering now comes from
|
||||
* @map/components/poster/PosterWall.vue, which country-posters (94 lines) and key-cities (75)
|
||||
* already consume. This profile supplies ONLY what cannot be shared: its data, its taxonomy and
|
||||
* its records provider.
|
||||
*
|
||||
* This is a SIBLING PROFILE in the same bundle (index.js does
|
||||
* import.meta.glob('./*.vue'), so dropping this file in registers it). It shares
|
||||
* the map's data/clusters.json — no second fetch, no duplicated source of truth.
|
||||
* WHAT COULD NOT BE SHARED, and why each is here rather than in the component:
|
||||
* REGION_OF / regionFor — the 100-list's own continental taxonomy, keyed by list id because a
|
||||
* selector can be a wagf_block, a country_codes array or an explicit
|
||||
* pg_slugs list and none of those implies a continent.
|
||||
* fetchListRecords — knows the people-groups API, the selector evaluator AND the pg_slugs
|
||||
* special case. Key Cities and Country Posters have entirely different
|
||||
* data shapes, which is exactly why the shared wall takes a PROVIDER.
|
||||
*
|
||||
* CROSS-PANE COMMS — the decoupling that matters: this wall NEVER imports the map.
|
||||
* It dispatches a window CustomEvent scoped by instanceId, so several embeds on one
|
||||
* page never cross-talk. The shell listens and drives the map pane.
|
||||
* THREE CAPABILITIES HAD TO BE ADDED TO THE SHARED WALL FIRST, over two ticks, because migrating
|
||||
* without them would have REGRESSED this wall rather than consolidated it:
|
||||
* cardStocks — the reader picks the prayer-card size at print time.
|
||||
* posterAction="print" — open the print dialog instead of saving a file.
|
||||
* hidePrayerListWhenSoon — no "coming soon" placeholder on a wall whose list DOES print.
|
||||
* The last two are a Driver ruling, not a preference (2026-09-23): "three print buttons, each
|
||||
* opening an in-browser print dialog, no silent file download as the default action. A raw
|
||||
* 'Download' button and a 'Prayer List: coming soon' placeholder were exactly the two buttons he
|
||||
* objected to."
|
||||
*
|
||||
* ASSETS — TWO SIZES, ONE OF WHICH SHIPS (corrected 2026-09-22; this header used to say
|
||||
* "POSTERS DO NOT EXIST YET. Nothing has been generated", which is no longer true and would
|
||||
* send a reader off to build a generator that already ran):
|
||||
* previews/<id>.webp — ~65KB each, 378 generated, SHIPPED in the default tree and served
|
||||
* live. These are what the wall paints, fetched lazily per card.
|
||||
* posters/<id>.webp — full print resolution, 378 generated, NOT shipped by default
|
||||
* (~42MB). See IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF:
|
||||
* bundling them pushed the tree to 80MB, git-packed to 57MiB, and the
|
||||
* catalog REJECTED the push — so the all-in version shipped nothing.
|
||||
* Export and print-preview no longer 404 on a default drop: BOTH now
|
||||
* fall back to fetching posters/<id>.html and rasterising it in the
|
||||
* browser (library/packet/poster-raster.js). Print was the odd one out
|
||||
* until 2026-09-24 — the same poster downloaded fine and PRINTED as
|
||||
* "Poster not available yet." bundlePosters:true or an assetsBase that
|
||||
* serves posters/ still short-circuits the fallback with the shipped webp.
|
||||
* posters/<id>.html — the poster SOURCE, what the fallback rasterises. Also not shipped by
|
||||
* default (28 zones, 27.9MB raw), so on a drop with neither file the
|
||||
* preview still refuses to open rather than printing a blank sheet.
|
||||
* The @error fallback to a labelled placeholder is still live and still the right behaviour —
|
||||
* it now covers the missing-poster case rather than the nothing-generated-yet case.
|
||||
* CROSS-PANE COMMS — unchanged and still the decoupling that matters: this wall NEVER imports the
|
||||
* map. The shared component dispatches the window CustomEvent named below, scoped by instanceId,
|
||||
* so several embeds on one page never cross-talk. The shell listens and drives the map pane.
|
||||
*
|
||||
* ASSETS. previews/<id>.webp (~65KB, 378 generated) SHIP and are what the wall paints. Full
|
||||
* posters/<id>.webp do NOT ship by default (~42MB); export and print fall back to fetching
|
||||
* posters/<id>.html and rasterising it in the browser. See
|
||||
* IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF.
|
||||
*/
|
||||
import { computed, inject, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// Export-modal geometry — see the note at .modal-scrim below, and library/utils/viewportScrim.js.
|
||||
import { useViewportScrim } from '@map/utils/viewportScrim.js'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import PosterWall from '@map/components/poster/PosterWall.vue'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// PORTED from the sibling map profile (upg-100-list.vue) rather than re-invented — same
|
||||
// normalize+evaluate pipeline it already uses to turn the live /people-groups/list API
|
||||
// payload + a list's `selector` into the exact PG rows that belong to that list. This is
|
||||
// the per-people-group data the UPG-100 LIST print and the CARD DECK print both need;
|
||||
// clusters.json itself only carries list-level rows (id/label/count/selector/bbox).
|
||||
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
|
||||
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
|
||||
import { normalizeRecords, extractRecords } from './data/normalize.js'
|
||||
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
|
||||
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
|
||||
const instanceId = computed(() => profileConfig.value?.instanceId || 'upg-100-wall')
|
||||
const isDark = computed(() => profileConfig.value?.isDark === true)
|
||||
|
||||
// Assets resolve RELATIVE to the host page so the tree stays drop-portable —
|
||||
// file://, a CDN subpath, Railway and Capacitor all behave identically.
|
||||
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
|
||||
// Full-size print poster. `.webp` is the ONLY format that exists: data/posters/ holds 378 .webp
|
||||
// and ZERO matching .png. This helper previously said `.png` and was UNCALLED — printPoster() and
|
||||
// exportPoster() each built the same URL inline — so the wrong extension was invisible, sitting
|
||||
// ready to break whoever "tidied up" by routing a call through it. Now it is the single source of
|
||||
// truth for both, and a format change has ONE place to happen.
|
||||
//
|
||||
// NOTE the full-size posters are NOT shipped in the default tree (42MB): see
|
||||
// IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF. This URL 404s unless the publisher sets
|
||||
// bundlePosters:true or points assetsBase at a host that serves posters/. Both the export and the
|
||||
// print-preview paths degrade honestly when it does (export reports an error; the preview window
|
||||
// shows "Poster not available yet" via the img onerror).
|
||||
const posterUrl = (id) => `${assetsBase.value}/posters/${id}.webp`
|
||||
|
||||
// Config-only, fail-closed API base — SAME resolution as the sibling map profile
|
||||
// (upg-100-list.vue:297). No default host: a dev harness that never sets
|
||||
// window.MAP_APP_API_URL simply gets no PG-level data, and the two print paths
|
||||
// below degrade honestly (see printList100Notice / printCardDeckNotice).
|
||||
// Config-only, fail-closed API base — SAME resolution as the sibling map profile. No default
|
||||
// host: a dev harness that never sets window.MAP_APP_API_URL simply gets no PG-level data, and
|
||||
// the shared wall hides the row-dependent buttons rather than failing at click time.
|
||||
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|
||||
|| import.meta.env.VITE_API_BASE_URL
|
||||
|| ''
|
||||
|
|
@ -125,6 +97,12 @@ const REGION_OF = {
|
|||
// section combined — so collapsing them hides the two largest prayer fields in
|
||||
// the dataset behind a single continental label. Matched by id prefix below.
|
||||
}
|
||||
// ⚠ TAKES AN ID, while the shared wall's groupBy takes the whole ITEM. The template
|
||||
// adapts it — `:group-by="(item) => regionFor(item.id)"` — rather than changing this
|
||||
// signature, because the id is what the taxonomy is keyed by and every prefix rule
|
||||
// below reads it directly. Passing regionFor straight in built and loaded cleanly and
|
||||
// then threw `c.startsWith is not a function` at first render: the whole wall was blank,
|
||||
// 0 cards, with nothing wrong at the source layer.
|
||||
const regionFor = (id) => {
|
||||
if (REGION_OF[id]) return REGION_OF[id]
|
||||
if (id.startsWith('asia-india-')) return 'India'
|
||||
|
|
@ -134,305 +112,14 @@ const regionFor = (id) => {
|
|||
if (id.startsWith('asia-china-')) return 'China + Mongolia'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
// One flat list from the three cluster groups — the same 27 the map paints.
|
||||
const lists = computed(() => [
|
||||
...(clusters.lists || []),
|
||||
...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || []),
|
||||
const items = computed(() => [
|
||||
...(clusterLists || []),
|
||||
...(clusterAsiaLists || []),
|
||||
...(clusterUncoveredLists || []),
|
||||
].filter(l => l && l.id))
|
||||
|
||||
// Sections ordered by TOTAL people groups (largest first), and cards within a
|
||||
// section by their own count — so the densest prayer need leads at both levels.
|
||||
const sections = computed(() => {
|
||||
const byRegion = new Map()
|
||||
for (const l of lists.value) {
|
||||
const r = regionFor(l.id)
|
||||
if (!byRegion.has(r)) byRegion.set(r, [])
|
||||
byRegion.get(r).push(l)
|
||||
}
|
||||
return [...byRegion.entries()]
|
||||
.map(([name, items]) => ({
|
||||
name,
|
||||
items: items.slice().sort((a, b) => (b.count || 0) - (a.count || 0)),
|
||||
total: items.reduce((n, x) => n + (x.count || 0), 0),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
})
|
||||
|
||||
// The card you most recently sent to the map stays marked, so scrolling back down
|
||||
// tells you where you left off rather than losing your place in 27 cards.
|
||||
const lastShown = ref(null)
|
||||
|
||||
const failed = ref({})
|
||||
const onImgError = (id) => { failed.value = { ...failed.value, [id]: true } }
|
||||
|
||||
function showOnMap(list) {
|
||||
// TOGGLE, not a one-way set. `lastShown` only ever assigned, so the green
|
||||
// highlight could MOVE between cards but never switch off — a control the user
|
||||
// can turn on but not off reads as broken even when the selection is correct.
|
||||
// Clicking the same card again clears it AND tells the map to deselect, so the
|
||||
// card and the map can never disagree about what is selected.
|
||||
const isOn = lastShown.value === list.id
|
||||
lastShown.value = isOn ? null : list.id
|
||||
// Scoped by instanceId so multiple embeds never cross-talk.
|
||||
window.dispatchEvent(new CustomEvent('doxa-upg-100-list:show-on-map', {
|
||||
detail: {
|
||||
instanceId: instanceId.value,
|
||||
listId: isOn ? null : list.id, // null = deselect
|
||||
label: isOn ? null : list.label,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// ─── PREVIEWS — bundled, static, instant ─────────────────────────────────────
|
||||
// These are DOWNSCALES OF THE REAL POSTERS (the SVG generator's output: title,
|
||||
// all people-group names de-overlapped into two gutters with leader lines, the
|
||||
// Mark 6:40 footer), not a live map screenshot.
|
||||
//
|
||||
// The wall previously rendered each card by spinning up a hidden Mapbox map,
|
||||
// waiting for tiles, and capturing the canvas. That was 2-3s PER CARD and
|
||||
// produced a near-white basemap with NO NAMES — which for a poster whose whole
|
||||
// purpose is praying for groups by name is the wrong artefact, not merely a
|
||||
// slow one. Serving pre-rendered previews fixes both at once: no map, no GPU,
|
||||
// no network per card.
|
||||
//
|
||||
// PREVIEWS ARE SERVED FROM assetsBase, NOT INLINED INTO THE BUNDLE.
|
||||
//
|
||||
// They used to be `import.meta.glob(..., eager: true)`, which base64-inlines them.
|
||||
// That was right at 13 previews (400x533 = 129KB) and wrong at 28: the v3 template
|
||||
// previews are 560x560 and total ~1.8MB, which took the bundle to 6MB. And because
|
||||
// the build is deliberately a single-file IIFE (`inlineDynamicImports: true`, so
|
||||
// the bundle stays drop-portable), the map, the list view AND the poster wall all
|
||||
// wait on ALL of it. That is the reported symptom exactly — "maps aren't loading
|
||||
// fast enough, and while I'm waiting I should be able to go to list view or poster
|
||||
// view but it's broken": the view switcher is not broken, it simply does not exist
|
||||
// yet while 6MB parses.
|
||||
//
|
||||
// `eager: false` does NOT help here — assetsInlineLimit is MAX_SAFE_INTEGER, so
|
||||
// every asset inlines regardless. Serving them from assetsBase — the same
|
||||
// convention posterUrl() already uses — keeps the bundle small AND keeps the tree
|
||||
// drop-portable (file://, CDN subpath, Railway and Capacitor all behave the same).
|
||||
// Each card then fetches one ~65KB WebP lazily, in parallel, only when scrolled to.
|
||||
const previewUrl = (id) => `${assetsBase.value}/previews/${id}.webp`
|
||||
// Which previews failed to load, so a card can fall back to its text placeholder.
|
||||
const missing = ref({})
|
||||
|
||||
// ─── EXPORT (full size) ──────────────────────────────────────────────────────
|
||||
// ★ THE HIDDEN-MAP EXPORT PIPELINE WAS REMOVED HERE, 2026-09-24, as DEAD CODE:
|
||||
// renderFor() had ZERO call sites and was the only caller of makeHiddenMap() and
|
||||
// loadPoster(); EXPORT_SPEC was never read. It dated from when this wall rendered
|
||||
// its own poster by spinning up an off-screen gl.Map and compositing tiles. Export
|
||||
// and print now take the shipped .webp, or rasterise posters/<id>.html in the
|
||||
// browser (library/packet/poster-raster.js) — no map instance is involved.
|
||||
//
|
||||
// ⚠ IT WAS NOT MERELY UNUSED. isMapLeaf() in generate-tree.mjs is a REGEX over the
|
||||
// file text (MAP_ENGINE_RE), so the `window.mapbox` + `gl` engine handle the removed
|
||||
// function used — spelled here in two pieces ON PURPOSE, because writing it whole
|
||||
// would re-flag this profile as a map and reinstate the very bug being described —
|
||||
// unreachable function — flagged this profile isMap=true. That flag in turn made
|
||||
// verify-map-paint.mjs EXCLUDE the profile, with a written justification that it
|
||||
// 'constructs a real gl.Map ON DEMAND during poster export'. True of the source,
|
||||
// false of the running product: the profile was excluded from a paint gate on the
|
||||
// strength of code that never ran. Deleting it makes the flag tell the truth.
|
||||
const exportState = ref(null) // { label, phase, message, pct, kind }
|
||||
|
||||
// Blob URLs minted for a rasterised print preview. They CANNOT be revoked when the
|
||||
// print window opens — the preview <img> is still loading from them, and revoking
|
||||
// early gives a blank sheet — so they are held until this element goes away.
|
||||
// One poster is ~1.5MB as a blob; a reader printing a dozen in one session would
|
||||
// otherwise pin ~18MB for the life of the page.
|
||||
const printObjectUrls = []
|
||||
onBeforeUnmount(() => {
|
||||
for (const u of printObjectUrls.splice(0)) { try { URL.revokeObjectURL(u) } catch { /* already gone */ } }
|
||||
})
|
||||
|
||||
// CARD STOCK for the prayer-card deck. The Driver asked to be able to "choose the
|
||||
// layout of the print dialog to choose the aspect ratio of these prayer cards", so
|
||||
// the stock is a user choice, not a constant. Poker (2.5x3.5in) is the default: it
|
||||
// is what commercial card printers, sleeves and boxes are built around.
|
||||
// @page takes these as explicit inches — a NAMED size silently falls back to letter
|
||||
// in Chromium, which is the exact bug this deck is being fixed for.
|
||||
// Both orientations, because the Driver asked for "horizontal cards" as well as
|
||||
// vertical ones. A landscape stock is not merely transposed: the renderer moves
|
||||
// the photo BESIDE the text, since a 46%-height band on a 2.5in-tall card is too
|
||||
// short to carry a face. The labels come from the renderer's own STOCK table so
|
||||
// this list cannot drift from what the renderer actually supports.
|
||||
const DECK_STOCKS = [
|
||||
{ id: 'poker', label: 'Card 2.5 × 3.5 in' },
|
||||
{ id: 'pokerLandscape', label: 'Card 3.5 × 2.5 in (landscape)' },
|
||||
{ id: 'postcard', label: 'Postcard 4 × 6 in' },
|
||||
{ id: 'postcardLandscape', label: 'Postcard 6 × 4 in (landscape)' },
|
||||
{ id: 'a6', label: 'A6 105 × 148 mm' },
|
||||
]
|
||||
const deckStock = ref('poker')
|
||||
// The deck follows the app's locale; packet-strings.json falls back PER KEY, and
|
||||
// the QR on each card encodes the LOCALISED profile URL, so a Spanish card scans
|
||||
// through to the Spanish page.
|
||||
const { locale: deckLocale } = useI18n()
|
||||
function deckLang() {
|
||||
const l = deckLocale && (deckLocale.value ?? deckLocale)
|
||||
return (typeof l === 'string' && l) || 'en'
|
||||
}
|
||||
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
||||
|
||||
// exportPoster()/downloadPacket()/download() REMOVED (2026-09-23) — the Driver's brief was
|
||||
// explicit: three print buttons, each opening an in-browser print dialog, no silent file
|
||||
// download as the default action. A raw "Download" button and a "Prayer List: coming soon"
|
||||
// placeholder were exactly the two buttons he objected to. Printing to PDF (Save as PDF in
|
||||
// the OS print dialog that printList100/printPoster/printCardDeck now all open) covers the
|
||||
// "get a file" need without a fourth competing button. posterUrl()/exportState/the modal
|
||||
// all stay — printPoster below still uses posterUrl(), and all three print paths still
|
||||
// share the exportState HUD for progress/errors.
|
||||
|
||||
// ─── SHARED PRINT MECHANISM ───────────────────────────────────────────────────
|
||||
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
|
||||
// deck) rather than three ad-hoc implementations.
|
||||
//
|
||||
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
|
||||
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
|
||||
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
|
||||
// its own to hang the call off).
|
||||
//
|
||||
// ── HISTORY, kept because the lesson outlives the code that taught it ──
|
||||
// This helper used to call window.open(). An earlier build passed `noopener`, which makes
|
||||
// window.open() return null BY SPECIFICATION — severing the opener relationship is exactly what
|
||||
// the flag does, so there was no handle to write into. Every click silently fell through to a
|
||||
// raw-content fallback, for every user. MEASURED: window.open('','_blank','noopener') -> null;
|
||||
// the identical call without the flag -> a usable handle. It was first blamed on the headless box;
|
||||
// it was not environmental, it was what the flag means.
|
||||
// The iframe below makes that class of failure unreachable — there is no popup to block and no
|
||||
// opener flag to get wrong — but the lesson stands: a flag that returns null by spec will look
|
||||
// exactly like an environment problem.
|
||||
// ★ IN-PLACE (2026-09-23) — a hidden same-page IFRAME, not a popup window.
|
||||
//
|
||||
// WHY THIS CHANGED, so nobody "restores" the popup as a regression fix: an EARLIER Driver ask
|
||||
// wanted a preview window ("show the poster in your browser to help you print it"), and this
|
||||
// helper was built around window.open() to serve it. The Driver has now REVERSED that: the print
|
||||
// dialog must open DIRECTLY, with no second browser window. That is a decision change, not a bug.
|
||||
//
|
||||
// An iframe is the right vehicle because it keeps the ENTIRE existing contract: callers still pass
|
||||
// one full <html> document string, we still document.write() it, and the markup's own
|
||||
// `<img onload> -> window.print()` still fires — only now `window` inside that document is the
|
||||
// iframe's contentWindow, so the dialog belongs to THIS page. All three print paths (poster /
|
||||
// list / card deck) stay on this one mechanism.
|
||||
//
|
||||
// It also deletes a whole failure mode: there is no popup blocker on an iframe, so the
|
||||
// "popup blocked -> fall back to opening the raw image" branch every caller carried is now dead.
|
||||
// The returned object keeps a truthy `.document`-less shape only so existing `if (!w)` guards stay
|
||||
// harmless; callers should no longer rely on a popup-blocked null.
|
||||
// ★ ONE FRAME MECHANISM, not a fourth copy. This was a local reimplementation of
|
||||
// library/composables/printInPlace.js — same off-screen iframe, same doc.write,
|
||||
// same autoprint script, differing only in an unused `title` argument. Four copies
|
||||
// of one mechanism is how a fix to the blank-first-page behaviour reaches one
|
||||
// caller and not the others. The shared helper takes (bodyHtml, opts), so the
|
||||
// title is dropped here: it was never used, the document's own <title> carries it.
|
||||
const openPrintWindow = (title, bodyHtml, opts) => openPrintFrame(bodyHtml, opts);
|
||||
|
||||
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
|
||||
// Print-window markup is built by string concatenation (no template-literal/backtick rule for
|
||||
// shadow CSS extends to every string-built document in this file for the same reason: one stray
|
||||
// character silently breaks the whole write()), so every field sourced from data (list labels, PG
|
||||
// names, country/religion labels) is routed through this before insertion.
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// PRINT a poster: open it in a print-friendly preview window and raise the browser's
|
||||
// print dialog, so the user can PREVIEW the poster in the browser and print it — not
|
||||
// just get a file. (Driver: "clicking a poster should open a print dialog and show the
|
||||
// poster in your browser to help you print it.") Download stays available as a fallback
|
||||
// (the export button), and if the popup is blocked we fall back to opening the image tab.
|
||||
async function printPoster(list) {
|
||||
if (exportState.value) return // one at a time (shares the HUD)
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
|
||||
message: 'preparing print preview', pct: 30 }
|
||||
try {
|
||||
let url = posterUrl(list.id)
|
||||
// ── THE SAME FALLBACK LADDER THE DOWNLOAD PATH ALREADY CLIMBS ───────────
|
||||
// ★ THE ASYMMETRY THIS FIXES. posters/*.webp is NOT shipped by default (42MB;
|
||||
// bundling it pushed the tree to 80MB, git-packed to 57MiB, and the catalog
|
||||
// rejected the push). The DOWNLOAD path handles that: PosterWall.vue fetches
|
||||
// posters/<id>.html and rasterises it in the browser. The PRINT path did not —
|
||||
// it handed the <img> a url it never checked and, on 404, replaced the whole
|
||||
// preview with "Poster not available yet."
|
||||
// So on a default drop the same poster DOWNLOADED fine and PRINTED as an error
|
||||
// message. The card that recorded this (IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-
|
||||
// TRADEOFF) calls it out precisely: "a print preview that opens on 'Poster not
|
||||
// available yet' is a worse first impression than a failed download."
|
||||
//
|
||||
// Probing costs one HEAD-like GET that is almost always a cache hit when the
|
||||
// image IS shipped, and only on a miss do we pay for the HTML.
|
||||
let res = null
|
||||
try { res = await fetch(url) } catch { /* offline/blocked — treated as a miss */ }
|
||||
if (!res || !res.ok) {
|
||||
exportState.value = { ...exportState.value, phase: 'render',
|
||||
message: 'generating the poster', pct: 55 }
|
||||
const htmlRes = await fetch(`${assetsBase.value}/posters/${list.id}.html`)
|
||||
// Still honest when there is genuinely nothing to render: the preview only
|
||||
// opens once we HAVE a poster, so the reader never sees a blank print dialog.
|
||||
if (!htmlRes.ok) throw new Error(`poster not available (${htmlRes.status})`)
|
||||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||||
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 })
|
||||
// ⚠ An SVG loaded through <img> is an isolated document: a lost stylesheet makes
|
||||
// every fill fall back to BLACK, with no error and a normal file size. Printing
|
||||
// a black square is worse than failing, so verify pixels before opening the
|
||||
// dialog — the same guard the download path uses.
|
||||
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank')
|
||||
url = URL.createObjectURL(blob)
|
||||
printObjectUrls.push(url)
|
||||
}
|
||||
// Preview + print in a dedicated window: a single centered, page-fit image and an
|
||||
// onload -> print() so the dialog opens on the poster the user is looking at. The
|
||||
// poster is a 3ft square print (see the template) → @page portrait, image scaled to
|
||||
// fit the sheet with a small margin.
|
||||
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
// PORTRAIT, explicitly. `size:auto` let the browser choose, and it chooses LANDSCAPE for a
|
||||
// square: the poster art is 3ft x 3ft (aspect-ratio:1/1, see the wall template below), so an
|
||||
// auto page has no reason to prefer the tall axis. The list and card-deck print paths in this
|
||||
// same file already pin `size:letter portrait`; the poster path was the odd one out.
|
||||
// (Driver: "print to a vertical piece of printer paper".)
|
||||
'<style>@page{size:letter portrait;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
'body{display:flex;align-items:center;justify-content:center}' +
|
||||
'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}' +
|
||||
// A SQUARE on a PORTRAIT sheet must fit by its SHORTER axis. The old print rule
|
||||
// (`max-height:none;width:100%`) scaled the square to the full page WIDTH, which on portrait
|
||||
// letter is taller than the printable height -> the poster overflowed onto a second page.
|
||||
// Constrain BOTH axes and let object-fit letterbox it inside the sheet instead.
|
||||
'@media print{body{height:auto}' +
|
||||
'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>' +
|
||||
'<body><img src="' + url + '" alt="' + title + '"' +
|
||||
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
|
||||
' onerror="document.body.innerHTML=\'<p style="font:16px system-ui;padding:24px">Poster not available yet.</p>\'">' +
|
||||
'</body></html>')
|
||||
if (!w) {
|
||||
// No longer "popup blocked" — an iframe cannot be blocked. This is now only reachable if the
|
||||
// frame has no contentDocument at all (a sandboxed/detached host), so the honest fallback is
|
||||
// the poster itself rather than a pop-up instruction the user cannot act on.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900); return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140), pct: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PER-LIST PEOPLE-GROUP RECORDS ─────────────────────────────────────────────
|
||||
// Both new print actions (the UPG-100 LIST and the CARD DECK) need the actual
|
||||
// people-group rows that belong to ONE list, not just its {id,label,count}
|
||||
// summary. This fetches the live API once per click, normalizes it with the
|
||||
// SAME normalize.js the sibling map profile uses (flattens the {value,label}
|
||||
// fields), then narrows to this list's members via the SAME selector-evaluator
|
||||
// the map uses to paint the tapestry — so "what's in this list" can never
|
||||
// disagree between the map, the list print and the card deck.
|
||||
// ─── THE RECORDS PROVIDER — the one thing that cannot be shared ──────────────
|
||||
async function fetchListRecords(list) {
|
||||
if (!API_BASE) throw new Error('no API configured')
|
||||
// image_url + primary_language are what make a PRAYER CARD rather than a data
|
||||
|
|
@ -462,429 +149,31 @@ async function fetchListRecords(list) {
|
|||
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
|
||||
}
|
||||
|
||||
// PRINT #1 (Driver's FIRST option, "that's what the whole app was about"): the UPG-100
|
||||
// LIST itself — the prayer list of up to 100 people groups this card represents, as a
|
||||
// print-paginated table (name / country / population / religion), opened straight into
|
||||
// the browser's print dialog. Same mechanism as printPoster(): a dedicated window,
|
||||
// document.write, print() on load — never a silent file download.
|
||||
async function printList100(list) {
|
||||
if (exportState.value) return
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'fetch',
|
||||
message: 'fetching people-group records', pct: 30 }
|
||||
try {
|
||||
const rows = await fetchListRecords(list)
|
||||
const title = `Doxa UPG-100 list — ${escapeHtml(list.label || list.id)}`
|
||||
const rowsHtml = rows.map((r) => (
|
||||
'<tr><td class="c-name">' + escapeHtml(r.name) + '</td>' +
|
||||
'<td>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</td>' +
|
||||
'<td class="c-num">' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</td>' +
|
||||
'<td>' + escapeHtml(r.religion_label || '—') + '</td>' +
|
||||
'<td class="c-pray">□</td></tr>'
|
||||
)).join('')
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>' +
|
||||
'@page{size:letter portrait;margin:14mm 12mm}' +
|
||||
'html,body{margin:0;background:#fff;color:#0f1419;' +
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
|
||||
'.hd{margin-bottom:14px}' +
|
||||
'.hd h1{margin:0 0 3px;font-size:18px}' +
|
||||
'.hd p{margin:0;font-size:11px;color:#5b6672}' +
|
||||
'table{width:100%;border-collapse:collapse;font-size:11px}' +
|
||||
'thead th{text-align:left;border-bottom:2px solid #0f1419;padding:5px 6px;' +
|
||||
'font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}' +
|
||||
'tbody td{padding:5px 6px;border-bottom:1px solid #e3e6ea}' +
|
||||
'.c-name{font-weight:600}.c-num{font-variant-numeric:tabular-nums}' +
|
||||
'.c-pray{width:26px;text-align:center;font-size:13px}' +
|
||||
'tbody tr{break-inside:avoid}' +
|
||||
'@media print{.hd{margin-top:0}}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="hd"><h1>' + title + '</h1>' +
|
||||
'<p>' + rows.length + ' people groups — pray for each one by name (Luke 9:14). ' +
|
||||
'Check the □ as you pray.</p></div>' +
|
||||
'<table><thead><tr><th>People group</th><th>Country</th><th>Population</th>' +
|
||||
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
// Not a pop-up blocker any more (this prints via a same-page iframe). Only a host that
|
||||
// denies us a contentDocument lands here, so say something the user can act on.
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: 'Could not open the print dialog in this view — try the Download button.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print dialog opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140) + (API_BASE ? '' :
|
||||
' (no API configured in this preview — set window.MAP_APP_API_URL)'), pct: 0 }
|
||||
}
|
||||
}
|
||||
// The wall's own copy. v-html in the shared component, so the <strong> lands as markup —
|
||||
// the emphasis is the point: praying for them BY NAME is the whole brief.
|
||||
const intro = 'Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed '
|
||||
+ 'the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — '
|
||||
+ 'praying for them <strong>by their people group name</strong>.'
|
||||
|
||||
// PRINT #3: the CARD DECK — one prayer card PER PEOPLE GROUP in this list, laid out for
|
||||
// a real printer with `break-after:page` between cards so each card lands on its own
|
||||
// sheet (or, cut down, its own card). New code — no card/deck generator exists anywhere
|
||||
// in root-dx-life or in this repo's library/ to port (searched; only a per-COUNTRY
|
||||
// pre-baked PDF pointer exists there, `prayer-card-{country}-{lang}.pdf`, not a per-PG
|
||||
// generator). Styling below borrows the poster-wall's own vocabulary (tape header, thin
|
||||
// card border, Material Light palette) instead of inventing a fourth visual language.
|
||||
// ── CARD DECK: the SHARED action ────────────────────────────────────────────
|
||||
// This function used to be a second, hand-maintained copy of the deck print —
|
||||
// same renderer call, same stock, same language resolution — living beside the
|
||||
// composable's. Two copies of one fact drift: a fix to either was invisible to
|
||||
// the other, which is the whole reason the wall was being consolidated.
|
||||
//
|
||||
// It now delegates to library/composables/usePosterPrints.js, sharing THIS wall's
|
||||
// exportState so the HUD keeps rendering one surface. The bundle still owns the
|
||||
// part that cannot be shared — fetchListRecords knows the people-groups API, the
|
||||
// selector evaluator and the pg_slugs special case — and hands it over as the
|
||||
// `records` provider.
|
||||
const prints = usePosterPrints({
|
||||
state: exportState,
|
||||
records: (list) => fetchListRecords(list),
|
||||
lang: () => deckLang(),
|
||||
stock: () => deckStock.value,
|
||||
label: (list) => list?.label || list?.id || '',
|
||||
})
|
||||
|
||||
async function printCardDeck(list) {
|
||||
await prints.printCardDeck(list)
|
||||
// Keep the preview-specific hint the shared action cannot know about: in a
|
||||
// dropped tree with no API configured the failure is a CONFIGURATION problem,
|
||||
// not a broken button, and saying so is the difference between "this is broken"
|
||||
// and "this needs a URL".
|
||||
const st = exportState.value
|
||||
if (st && st.phase === 'error' && !API_BASE && !/MAP_APP_API_URL/.test(st.message)) {
|
||||
exportState.value = { ...st,
|
||||
message: `${st.message} (no API configured in this preview — set window.MAP_APP_API_URL)` }
|
||||
}
|
||||
}
|
||||
|
||||
function dismissExport() { exportState.value = null }
|
||||
|
||||
useShadowStyles(
|
||||
'.wall{position:absolute;inset:0;overflow-y:auto;padding:22px 18px 44px;' +
|
||||
"background:#f6f7f9;color:#0f1419;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
|
||||
'.wall.is-dark{background:#0a1410;color:#e5e7eb}' +
|
||||
'.wall-h{margin:0 0 6px;font-size:19px;font-weight:700;text-align:center}' +
|
||||
// Section tag — a small pill under the heading naming the mobilization track
|
||||
// this wall belongs to, matching the nav section it sits under.
|
||||
'.wall-tag{margin:0 auto 12px;width:fit-content;padding:3px 11px;border-radius:999px;' +
|
||||
'background:rgba(16,185,129,.13);color:#0b7a5b;border:1px solid rgba(16,185,129,.28);' +
|
||||
'font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}' +
|
||||
'.is-dark .wall-tag{background:rgba(16,185,129,.16);color:#6ee7b7;border-color:rgba(110,231,183,.32)}' +
|
||||
'.wall-sub{margin:0 auto 22px;max-width:620px;font-size:12.5px;line-height:1.55;' +
|
||||
'text-align:center;opacity:.75}' +
|
||||
// The bolded phrase must not be dimmed by the paragraph opacity, or the emphasis
|
||||
// is lost — lift it back to full weight and full contrast.
|
||||
'.wall-sub strong{font-weight:700;opacity:1;color:inherit}' +
|
||||
// The grid: auto-fill so it reflows from 1 column on a phone to 4+ on a desktop
|
||||
// without a single breakpoint.
|
||||
// GAP must clear the OVERHANGING TAPES, not just the card boxes. The header tape
|
||||
// sits at top:-11px and the footer fieldset at bottom:-14px, so a 26px row gap
|
||||
// left only ~1px of real air between one card's footer and the next card's
|
||||
// header — which is why the wall read as one dense block rather than a set of
|
||||
// separate posters. 26 -> 64 vertical gives ~39px of true breathing room.
|
||||
// Column gap widens too: posters need margin to read as individual objects.
|
||||
'.grid{display:grid;gap:64px 30px;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));' +
|
||||
'max-width:1180px;margin:0 auto}' +
|
||||
// A card is a poster taped to a wall: the tape strips are NARROWER than the card
|
||||
// and overlap its edges, which is what sells the effect.
|
||||
// Interior padding is the poster's MOUNT — the white border a framed print sits
|
||||
// in. Widened top/bottom so the artwork is not crowded by its own tapes.
|
||||
'.card{position:relative;background:#fff;border:1px solid #e3e6ea;border-radius:3px;' +
|
||||
'padding:24px 12px 26px;box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' +
|
||||
'transition:transform .16s ease,box-shadow .16s ease}' +
|
||||
'.card:hover{transform:translateY(-3px);box-shadow:0 3px 8px rgba(0,0,0,.14),0 12px 28px rgba(0,0,0,.10)}' +
|
||||
'.is-dark .card{background:#132019;border-color:rgba(110,231,183,.20)}' +
|
||||
// box-sizing is NOT inherited into a shadow root from the host page's reset, so
|
||||
// without this the fieldset's padding+border ADD to its 82% while the header
|
||||
// button's do not — the two strips came out 10px apart despite the same width.
|
||||
'.tape{box-sizing:border-box;position:absolute;left:50%;transform:translateX(-50%);z-index:2;' +
|
||||
'font-size:10.5px;font-weight:600;letter-spacing:.02em;cursor:pointer;' +
|
||||
'border:1px solid rgba(0,0,0,.08);background:#eef2f6;color:#23303c}' +
|
||||
'.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' +
|
||||
// ★ nowrap: a 3-DIGIT COUNT MUST NOT MAKE THE PILL TWO LINES TALL (2026-09-23).
|
||||
// The tape is a fixed 82% of the card, so "116 UPG · Show on map" wrapped while
|
||||
// "92 UPG · Show on map" did not — MEASURED on the shipped wall at 1440px, the same
|
||||
// pill rendered h=34 for 3-digit counts and h=22 for 2-digit ones, a 12px step that
|
||||
// reads as two different components sitting side by side in one row.
|
||||
// The sibling footer pill (.tape-l, below) already carries white-space:nowrap, so this
|
||||
// was an inconsistency inside one component rather than a design question — and
|
||||
// .hcount's font-variant-numeric:tabular-nums shows the author already wanted digit
|
||||
// width not to move things. Wrapping undid that intent.
|
||||
'.tape-h{top:-11px;width:82%;padding:4px 8px;border-radius:999px;white-space:nowrap;' +
|
||||
'display:flex;align-items:center;justify-content:center;gap:5px}' +
|
||||
'.tape-h:hover{background:#e2e8ee}' +
|
||||
// The COUNT leads the header: it is the one fact that distinguishes one list
|
||||
// from another at a glance, and the reason to pray this one.
|
||||
// "92 UPG" — the unit is carried WITH the number so the header states what it
|
||||
// counts. Bold because it is the fact that distinguishes one card from the next.
|
||||
'.hcount{font-weight:800;font-variant-numeric:tabular-nums}' +
|
||||
'.hsep{opacity:.45}' +
|
||||
'.hlabel{font-weight:600}' +
|
||||
// A card you sent to the map stays marked, so scrolling back down tells you
|
||||
// where you left off instead of losing your place among 27 cards.
|
||||
'.card.is-last{box-shadow:0 0 0 2px #10b981,0 6px 18px rgba(0,0,0,.10)}' +
|
||||
'.card.is-last .tape-h{background:#10b981;color:#04241a;border-color:#10b981}' +
|
||||
// FOOTER: a pill that opens two explicit print choices. "Prayer packet" was
|
||||
// replaced because it did not say what you would actually get.
|
||||
// FOOTER FIELDSET — mirrors the header's Select View control (a real
|
||||
// <fieldset> + <legend> with the legend sitting on the top border line), so the
|
||||
// card footer and the app header speak the same design language.
|
||||
// SQUARED edges (not the rounded pill it replaced), and the SAME 82% width as
|
||||
// the header tape so the two strips align as a matched pair top and bottom.
|
||||
// Still narrower than the card itself, so its edge frames the artefact.
|
||||
// Card footer WRAPS like text: two buttons sit side by side when they fit, one
|
||||
// per line when they do not, and NEITHER stretches to fill the row just because
|
||||
// it is alone on its line (2026-09-23). `flex:1` + `white-space:nowrap` on .pbtn
|
||||
// used to force every button to the row's full width AND forbid its label from
|
||||
// wrapping — together, one very wide button per line no matter how short the
|
||||
// label. Three buttons went from short filler text to real labels ("Print a
|
||||
// UPG-100 list" / "Print a UPG prayer poster" / "Print a UPG card deck"), which
|
||||
// is what made the latent one-button-per-row problem visible. `.pfs` is no
|
||||
// longer a flex row; it wraps, and each `.pbtn` sizes to its own content instead
|
||||
// of the row.
|
||||
// ★ SIT ON THE CARD'S EDGE, NOT ON THE ARTWORK (Driver, 2026-09-23: "it's overlapping the poster
|
||||
// too much... it's supposed to be near the edge, slightly overlapping the white edge, but NOT the
|
||||
// blue outline preview").
|
||||
//
|
||||
// THE MEASUREMENT THAT MISLED ME FIRST: at bottom:-14px the fieldset's top was 12px above the
|
||||
// image element's bottom, and `img{object-fit:contain}` made "12px inside the image box" LOOK like
|
||||
// letterboxed white margin. It is not — these posters fill the card's WIDTH, so contain letterboxes
|
||||
// at the SIDES, and the bottom 12px is real artwork. Reading the screenshot showed the legend
|
||||
// printed across the poster's own footer text. Geometry said white space; the image said otherwise.
|
||||
//
|
||||
// bottom:-26px clears the artwork: the fieldset's top now lands below the image box, so only its
|
||||
// top border grazes the card edge — "slightly overlapping the white edge" as asked. The negative
|
||||
// offset is kept (rather than moving the row into flow) because the tape-header at top:-11px uses
|
||||
// the same overhang idiom, and the 26px row gap noted at :542 was sized for this pairing.
|
||||
// ★ TWO BUTTONS PER LINE, NEVER THREE LINES (Driver, 2026-09-23, second pass on this row):
|
||||
// "nothing should overlap that blue outline... it shouldn't have one option per line, because
|
||||
// that's 3 lines of height — increase the width of the print options slightly so you can fit two
|
||||
// per line." The mechanism that made 3 lines overlap the outline: this fieldset is anchored
|
||||
// bottom:-26px and GROWS UPWARD as content wraps, so every extra line pushes its top further into
|
||||
// the image — and the blue hover outline lives on the image (.img.is-clickable:hover, line ~661).
|
||||
// At width:82% the three labels fell one per line; 96% lets two share a line, so the box stays
|
||||
// two lines tall and its top stays below the artwork. Same overhang idiom, wider row.
|
||||
'.pfs{position:absolute;bottom:-26px;left:50%;transform:translateX(-50%);' +
|
||||
'width:96%;margin:0;padding:0 3px 3px;border:1px solid rgba(0,0,0,.14);' +
|
||||
'border-radius:2px;background:#fff;display:flex;flex-wrap:wrap;gap:1px 2px;' +
|
||||
'justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.10)}' +
|
||||
'.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' +
|
||||
'.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' +
|
||||
'letter-spacing:.6px;font-weight:700;color:#8a93a0}' +
|
||||
'.is-dark .pfs > legend{color:#7f9c8c}' +
|
||||
// Buttons mirror .seg button: text-only with an underline accent on hover,
|
||||
// not filled chips — the same restraint the header uses. Content-sized (no
|
||||
// flex:1), with a max-width so one very long label still wraps to two lines
|
||||
// rather than pushing the button wider than the card.
|
||||
'.pbtn{position:relative;flex:0 1 auto;max-width:100%;border:0;background:none;' +
|
||||
'color:#5b6672;font:inherit;font-size:10.5px;font-weight:600;padding:2px 6px 3px;' +
|
||||
'cursor:pointer;white-space:normal;text-align:center;line-height:1.25;' +
|
||||
'transition:color .15s}' +
|
||||
'.pbtn::after{content:"";position:absolute;left:6px;right:6px;bottom:0;height:2px;' +
|
||||
'border-radius:2px;background:transparent;transition:background .15s}' +
|
||||
'.pbtn:hover{color:#10b981}' +
|
||||
'.pbtn:hover::after{background:#10b981}' +
|
||||
// CARD STOCK picker — sits with the print buttons because it changes what the
|
||||
// print dialog PRODUCES (it becomes the @page size). Quieter than the buttons:
|
||||
// it is a modifier, not an action.
|
||||
'.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
|
||||
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
|
||||
'padding:2px 4px;cursor:pointer;line-height:1.25}' +
|
||||
'.pstock:hover{border-color:#10b981;color:#10b981}' +
|
||||
'.is-dark .pstock{background:#12211a;border-color:#2c4a3b;color:#9fb3a8}' +
|
||||
'.is-dark .pbtn{color:#9fb3a8}' +
|
||||
// SECTIONS.
|
||||
// Sections need more separation than the cards within them, or the regional
|
||||
// grouping stops reading as a grouping.
|
||||
'.sec{max-width:1180px;margin:0 auto 56px}' +
|
||||
'.sec-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 14px;' +
|
||||
'padding-bottom:7px;border-bottom:1px solid rgba(0,0,0,.10)}' +
|
||||
'.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12)}' +
|
||||
'.sec-name{margin:0;font-size:15px;font-weight:700;letter-spacing:.01em}' +
|
||||
'.sec-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}' +
|
||||
// Poster aspect is 3ft x 3ft SQUARE — the real print ratio of the v3 template,
|
||||
// so the wall previews the actual artefact rather than an arbitrary box.
|
||||
// (Was 2/3 for the old 24x36 portrait sheets.)
|
||||
'.main{position:relative;aspect-ratio:1/1;border:1px solid #dfe3e7;overflow:hidden;' +
|
||||
'background:#fbfcfd;display:flex;align-items:center;justify-content:center}' +
|
||||
'.is-dark .main{background:#0c1712;border-color:rgba(255,255,255,.08)}' +
|
||||
// CONTAIN, not cover. `cover` crops to fill — on a square sheet in a portrait
|
||||
// box that sliced off the title and the footer scripture, which are two of the
|
||||
// three things the template is built to make readable.
|
||||
'.img{width:100%;height:100%;object-fit:contain;display:block}' +
|
||||
'.img.is-clickable{cursor:zoom-in}' +
|
||||
'.img.is-clickable:hover{outline:2px solid #38bdf8;outline-offset:-2px}' +
|
||||
'.img.is-clickable:focus-visible{outline:2px solid #38bdf8;outline-offset:-2px}' +
|
||||
'.pbtn-print{font-weight:600}' +
|
||||
'.ph{padding:14px 10px;text-align:center}' +
|
||||
'.ph-label{font-size:12.5px;font-weight:700;line-height:1.3}' +
|
||||
'.ph-count{margin-top:5px;font-size:11px;opacity:.7}' +
|
||||
'.ph-note{margin-top:10px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;opacity:.45}' +
|
||||
// Toast for the not-yet-wired actions.
|
||||
// Per-card rendering spinner.
|
||||
'.spinner{width:22px;height:22px;margin:0 auto 8px;border:2px solid rgba(0,0,0,.12);' +
|
||||
'border-top-color:rgba(0,0,0,.45);border-radius:50%;animation:upgspin .8s linear infinite}' +
|
||||
'.is-dark .spinner{border-color:rgba(255,255,255,.16);border-top-color:rgba(255,255,255,.55)}' +
|
||||
'@keyframes upgspin{to{transform:rotate(360deg)}}' +
|
||||
// EXPORT MODAL. The intent is right — the render blocks the user's intent, not merely this
|
||||
// pane — but the old reasoning here was FALSE and worth naming, because it reads as reassuring:
|
||||
// it said "position:fixed inside a shadow root is still viewport-relative, so this covers the
|
||||
// whole app". The premise is TRUE and IRRELEVANT. A shadow boundary scopes styles and
|
||||
// selectors; it does NOT establish a containing block. contain:layout|paint on an ANCESTOR
|
||||
// does — and VerticalTabsLayout sets exactly that on .vtl-content, so in the cap-5 shell this
|
||||
// scrim covered the PANE only: MEASURED 1024x793 @ (256,107) at a 1280x900 viewport, leaving
|
||||
// the 256px rail and 107px header uncovered and CLICKABLE behind a "modal".
|
||||
// GEOMETRY IS NOW SET INLINE by useViewportScrim (it measures the real origin and subtracts it;
|
||||
// verified 0,0 1280x900). The declarations below are the LOOK only — do not re-add geometry,
|
||||
// and do not re-derive the shadow-root reassurance: it answers a question nobody asked.
|
||||
'.modal-scrim{position:fixed;inset:0;z-index:1000;background:rgba(8,12,16,.55);' +
|
||||
'display:grid;place-items:center;padding:22px}' +
|
||||
'.modal{width:min(340px,100%);background:#fff;color:#0f1419;border-radius:14px;' +
|
||||
'padding:20px 22px;box-shadow:0 10px 40px rgba(0,0,0,.35);text-align:center}' +
|
||||
'.is-dark .modal{background:#132019;color:#e5e7eb}' +
|
||||
'.modal-kind{font-size:10px;text-transform:uppercase;letter-spacing:.1em;opacity:.55}' +
|
||||
'.modal-title{margin:3px 0 14px;font-size:15px;font-weight:700;line-height:1.3}' +
|
||||
'.bar{height:5px;border-radius:99px;background:rgba(0,0,0,.10);overflow:hidden}' +
|
||||
'.is-dark .bar{background:rgba(255,255,255,.12)}' +
|
||||
'.bar-fill{height:100%;background:#10b981;border-radius:99px;transition:width .25s ease}' +
|
||||
'.modal-phase{margin-top:10px;font-size:12.5px;font-weight:600}' +
|
||||
'.modal-hint{margin-top:6px;font-size:11px;line-height:1.5;opacity:.62}' +
|
||||
'.modal-soon{font-size:13px;line-height:1.5;color:#5f5849;margin:6px 0 14px}' +
|
||||
'.modal-err{font-size:12.5px;line-height:1.55;color:#b42318;margin-bottom:14px}' +
|
||||
'.is-dark .modal-err{color:#fda29b}' +
|
||||
'.modal-btn{border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit;' +
|
||||
'font:inherit;font-size:12.5px;padding:6px 18px;border-radius:8px;cursor:pointer}' +
|
||||
'.modal-btn:hover{background:rgba(0,0,0,.06)}',
|
||||
'upg-poster-wall',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wall" :class="{ 'is-dark': isDark }">
|
||||
<h2 class="wall-h">My UPG 100 List — Prayer Lists & Posters</h2>
|
||||
<div class="wall-tag">Prayer Mobilization</div>
|
||||
<p class="wall-sub">
|
||||
Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000
|
||||
(Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall —
|
||||
praying for them <strong>by their people group name</strong>.
|
||||
</p>
|
||||
|
||||
<!-- One block per region, ordered by total people groups. -->
|
||||
<section v-for="sec in sections" :key="sec.name" class="sec">
|
||||
<div class="sec-head">
|
||||
<h3 class="sec-name">{{ sec.name }}</h3>
|
||||
<span class="sec-count">{{ sec.total.toLocaleString() }} people groups · {{ sec.items.length }} {{ sec.items.length === 1 ? 'list' : 'lists' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div v-for="list in sec.items" :key="list.id" class="card"
|
||||
:class="{ 'is-last': lastShown === list.id }" :data-list-id="list.id">
|
||||
|
||||
<!-- HEADER tape: the PG count + the map action, so a card says what it
|
||||
holds and where it lives without opening anything. -->
|
||||
<button type="button" class="tape tape-h"
|
||||
:aria-label="`Show ${list.label} on the map`" @click="showOnMap(list)">
|
||||
<span class="hcount">{{ list.count }} UPG</span>
|
||||
<span class="hsep">·</span>
|
||||
<span class="hlabel">Show on map</span>
|
||||
</button>
|
||||
|
||||
<div class="main">
|
||||
<!-- The placeholder is now driven by a LOAD FAILURE rather than by a
|
||||
missing inlined asset: with previews served from assetsBase we do
|
||||
not know a poster is absent until its fetch 404s. -->
|
||||
<!-- Click (or Enter/Space) the poster to PREVIEW + PRINT it in the browser —
|
||||
not a silent file download. (Driver: show the poster in the browser to
|
||||
help print it.) The Download button below stays the secondary save path. -->
|
||||
<img v-if="!missing[list.id]" class="img is-clickable" :src="previewUrl(list.id)"
|
||||
:alt="`${list.label} — UPG 100 list prayer poster (click to preview and print)`"
|
||||
loading="lazy" decoding="async" role="button" tabindex="0"
|
||||
:title="`Preview & print the ${list.label} poster`"
|
||||
@click="printPoster(list)"
|
||||
@keydown.enter.prevent="printPoster(list)" @keydown.space.prevent="printPoster(list)"
|
||||
@error="missing[list.id] = true" />
|
||||
<div v-else class="ph">
|
||||
<div class="ph-label">{{ list.label }}</div>
|
||||
<div v-if="list.count" class="ph-count">{{ list.count }} people groups</div>
|
||||
<div class="ph-note">poster not generated yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER: a real <fieldset> + <legend>, the SAME structure as the
|
||||
header's Select View control, so the two read as one design language.
|
||||
Both options are visible side by side — no menu to open, because on a
|
||||
card the choice is the whole point and hiding it behind a click costs
|
||||
a step for nothing. -->
|
||||
<fieldset class="tape pfs">
|
||||
<legend>Print Options</legend>
|
||||
<!-- Driver, 2026-09-23: "It should say PRINT... open up a print dialog inside of
|
||||
your browser, not bring you to a new page... render or generate that poster
|
||||
and then let you print it out to PDF or print it directly to a printer." All
|
||||
THREE open the SAME in-browser print-preview + dialog mechanism
|
||||
(openPrintWindow, shared by printList100/printPoster/printCardDeck) — no
|
||||
silent file download as a primary action. LIST leads because it is what the
|
||||
whole app is about (Driver's ordering). -->
|
||||
<!-- ★ THE LEGEND ALREADY SAYS "PRINT OPTIONS" (line above), so repeating "Print a" in
|
||||
every button is three redundant words competing for the row's width. Labels are the
|
||||
OBJECT only; the verb lives in the legend that frames them. The emoji goes for the
|
||||
same reason — a printer glyph beside a "Print Options" legend adds no information
|
||||
and costs horizontal space that pushed the third button onto its own line.
|
||||
★ THE ARIA-LABEL KEEPS THE FULL SENTENCE. A screen-reader user does not get the
|
||||
legend's framing as reliably as a sighted user reading down the fieldset, and a
|
||||
control announced as "card deck" is ambiguous — so the visible label shortens while
|
||||
the accessible name stays "Print the <list> UPG card deck". check:a11y-button-names
|
||||
requires a name; this keeps it descriptive rather than merely present. -->
|
||||
<button type="button" class="pbtn pbtn-print" @click="printList100(list)"
|
||||
:aria-label="`Print the ${list.label} UPG-100 list`">UPG-100 list</button>
|
||||
<button type="button" class="pbtn pbtn-print" @click="printPoster(list)"
|
||||
:aria-label="`Print the ${list.label} UPG prayer poster`">UPG prayer poster</button>
|
||||
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
|
||||
:aria-label="`Print the ${list.label} UPG card deck`">card deck</button>
|
||||
<!-- CARD STOCK. The Driver asked to "choose the layout of the print
|
||||
dialog to choose the aspect ratio of these prayer cards" — so the
|
||||
size is picked here and becomes the @page size, which is what makes
|
||||
the print dialog show CARDS instead of letter paper. -->
|
||||
<select class="pstock" v-model="deckStock"
|
||||
aria-label="Prayer card size"
|
||||
@click.stop>
|
||||
<option v-for="s in DECK_STOCKS" :key="s.id" :value="s.id">{{ s.label }}</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- EXPORT MODAL — reports the library's REAL render phase, not a bare spinner.
|
||||
A poster render spins up a hidden map, waits for tiles, then composites;
|
||||
on a phone that is several seconds and an unlabelled spinner reads as a
|
||||
hang. Phase + message come straight from useMapPoster's progress ref. -->
|
||||
<div v-if="exportState" ref="scrimRef" :style="scrimStyle" class="modal-scrim" @click.self="exportState.phase === 'error' && dismissExport()">
|
||||
<div class="modal" role="dialog" aria-live="polite">
|
||||
<div class="modal-kind">{{ exportState.kind }}</div>
|
||||
<div class="modal-title">{{ exportState.label }}</div>
|
||||
|
||||
<template v-if="exportState.phase === 'error'">
|
||||
<div class="modal-err">{{ exportState.message }}</div>
|
||||
<button type="button" class="modal-btn" @click="dismissExport">Close</button>
|
||||
</template>
|
||||
|
||||
<!-- COMING SOON — deliberately not styled as an error. -->
|
||||
<template v-else-if="exportState.phase === 'soon'">
|
||||
<div class="modal-soon">{{ exportState.message }}</div>
|
||||
<button type="button" class="modal-btn" @click="dismissExport">Close</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="bar"><div class="bar-fill" :style="{ width: exportState.pct + '%' }"></div></div>
|
||||
<div class="modal-phase">{{ exportState.message }}</div>
|
||||
<div class="modal-hint">
|
||||
Rendering a print-quality image in your browser — nothing is uploaded.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PosterWall
|
||||
:items="items"
|
||||
:group-by="(item) => regionFor(item.id)"
|
||||
:instance-id="instanceId"
|
||||
:is-dark="isDark"
|
||||
:assets-base="assetsBase"
|
||||
event-name="doxa-upg-100-list:show-on-map"
|
||||
heading="My UPG 100 List — Prayer Lists & Posters"
|
||||
tag="Prayer Mobilization"
|
||||
:intro="intro"
|
||||
unit="UPG"
|
||||
item-noun="list"
|
||||
:records="fetchListRecords"
|
||||
:card-stocks="true"
|
||||
poster-action="print"
|
||||
:hide-prayer-list-when-soon="true"
|
||||
download-prefix="doxa-upg-100"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" 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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
78
public/doxa-maps/country-posters/country-posters.js
generated
78
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
2
public/doxa-maps/key-cities/key-cities.js
generated
2
public/doxa-maps/key-cities/key-cities.js
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
|
|
@ -154,7 +154,7 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "doxa-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ",
|
||||
"integrity": "sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
|
|
@ -174,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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" 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-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
"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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" 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-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
"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-x2PgKDm3BUb0Ivdvt7Q9tlZi8qF5ehMLDDIHG8joHZWZL3CBHpPc9XClPLUy3biQ\" 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>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-LZBRI3Sf1FcuRUJ31vsggCol2KyFw9gz0tpnWqGGhoLYa6WfEdlEjiFIi6WRztx+\" 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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -658,7 +658,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm",
|
||||
"integrity": "sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -670,7 +670,7 @@
|
|||
{
|
||||
"profile": "poster-wall",
|
||||
"file": "poster-wall.vue",
|
||||
"description": "A wall of printable poster cards, one per 100-list entry, each with a \"Show on map\" action that",
|
||||
"description": "A wall of printable poster cards, one per 100-list entry, each with a \"Show on map\"",
|
||||
"element": "my-upg-100-list-map",
|
||||
"type": "single",
|
||||
"isMap": false,
|
||||
|
|
@ -678,7 +678,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -692,7 +692,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -706,7 +706,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -856,7 +856,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z",
|
||||
"integrity": "sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -876,7 +876,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/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=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -890,7 +890,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -904,7 +904,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-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=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
186
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
186
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4CZZxz5EsiINnWC64V3JiNrhhA80LUf5NdSVFJFYR4Mm9WhsSvKYIZq4DOJvYCXm" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-BCy7+pyYhtlGp8tN46t6OmGZDPljA1FT2tpKjWo/BU4B4EKS6ForSRyUMGjjoEI1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-e0sCd920Vld42biAejKdjIEi9mJBH/hfSQItXFRCc7LEGzsTw5JCjxO5WVsQ3V9z" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-GD5wTtQMUZ+DFrz9n2VpmoxXAdX7dWkcngR593xGEsD6j/dwi+ETAoyYqz3cv8TL" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue