# Conflicts: # public/doxa-maps/index.html # public/doxa-maps/manifest.json # public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js # public/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html # public/doxa-maps/regions-dashboard/region-dashboards/index.html # public/doxa-maps/regions-dashboard/regions-dashboard.js # public/doxa-maps/regions-dashboard/regions-list/index.html # public/doxa-maps/regions-dashboard/regions-mfe/index.html
This commit is contained in:
commit
3bafe7cd55
17 changed files with 554 additions and 168 deletions
47
library/composables/printInPlace.js
Normal file
47
library/composables/printInPlace.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* printInPlace.js — SHARED (@map/composables/printInPlace.js).
|
||||
*
|
||||
* ★ THE ONE print mechanism, graduated VERBATIM from my-upg-100-list/poster-wall.vue
|
||||
* openPrintWindow() (commit e957cbe "Print the poster in place, on a portrait sheet") on the
|
||||
* Driver's ruling that killed the pop-up path: print must happen IN PLACE — no new browser
|
||||
* window, no pop-up blocker, the dialog rises over the page the user is looking at.
|
||||
*
|
||||
* WHY IT GRADUATES (library high bar: reusable + parameterized, 2+ consumers): the
|
||||
* prayer-walk-planner's print still used window.open(), and that is not just the rejected UX --
|
||||
* it is AGENT-HOSTILE. window.open() without a user gesture is pop-up-blocked, so
|
||||
* PrayerWalkPlanner.print() called from an agent (the Driver: "the agent can also create
|
||||
* polygons and trigger the print function") silently did nothing. An iframe cannot be blocked
|
||||
* and needs no gesture, which is what makes the same call work for a human click AND an agent.
|
||||
*
|
||||
* Mechanics preserved from the poster-wall original, each for a measured reason:
|
||||
* - OFF-SCREEN 1x1, not display:none — a display:none iframe does not lay out in every
|
||||
* engine, and a zero-height layout can print a blank first page.
|
||||
* - autoprint appends the print bootstrap AFTER the caller's markup so it runs once the
|
||||
* document is parsed (matches <img onload> timing).
|
||||
* - afterprint + a 60s floor reclaim the frame, so repeated prints never leak nodes.
|
||||
*
|
||||
* Returns the frame's contentWindow, or null when the host is sandboxed/detached (caller
|
||||
* keeps its own fallback).
|
||||
*/
|
||||
export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.setAttribute('aria-hidden', 'true')
|
||||
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;border:0'
|
||||
document.body.appendChild(frame)
|
||||
|
||||
const doc = frame.contentDocument
|
||||
if (!doc) { frame.remove(); return null }
|
||||
doc.open()
|
||||
doc.write(bodyHtml)
|
||||
if (autoprint) {
|
||||
doc.write(
|
||||
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
|
||||
}
|
||||
doc.close()
|
||||
|
||||
const drop = () => { if (frame.parentNode) frame.remove() }
|
||||
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
|
||||
setTimeout(drop, 60000)
|
||||
|
||||
return frame.contentWindow
|
||||
}
|
||||
|
|
@ -7,34 +7,21 @@
|
|||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: #f3f3f1; color: #3b463d; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 20px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #6d766e; font-size: 13px; margin: 6px 0 24px; line-height: 1.6; }
|
||||
.sub code { background: rgba(59, 70, 61, 0.08); padding: 1px 6px; border-radius: 4px; }
|
||||
.frame {
|
||||
height: 640px; border: 1px solid #d1d4cd; border-radius: 8px; overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
prayer-walk-planner { display: block; width: 100%; height: 100%; }
|
||||
/* ★ FULL-VIEWPORT MOUNT (BUG-PRAYER-WALK-PLANNER-MAP-DOES-NOT-FILL-THE-PAGE). This page used
|
||||
to stage the element inside a centred 1100px card with a fixed 640px .frame — measured on
|
||||
the dev server, the map painted a 781x584 window at y=206 in a 1440x900 viewport, which is
|
||||
exactly the Driver's report: "the map is showing up but it's not filling the full page."
|
||||
The BUILT leaf already mounts full-viewport; the dev harness now matches it (two-host rule:
|
||||
what the Driver sees on the dev server must be what the leaf ships). The client-side API
|
||||
note lives in the component header now — a planner is a full-page tool, not a demo card:
|
||||
document.querySelector('prayer-walk-planner').addPolygon(coords, title) /
|
||||
window.PrayerWalkPlanner.first(). */
|
||||
html, body { height: 100%; background: #f3f3f1; }
|
||||
prayer-walk-planner { position: fixed; inset: 0; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>prayer-walk-planner · staging</h1>
|
||||
<p class="sub">
|
||||
Draw a polygon, batch-select zones, click Print to open an in-browser print dialog.
|
||||
Persisted in <code>localStorage</code> only — no backend. Client-side API:
|
||||
<code>document.querySelector('prayer-walk-planner').addPolygon(coords, title)</code> or
|
||||
<code>window.PrayerWalkPlanner.first()</code> — see the component header for the full surface.
|
||||
</p>
|
||||
<div class="frame">
|
||||
<prayer-walk-planner instance-id="staging"></prayer-walk-planner>
|
||||
</div>
|
||||
</div>
|
||||
<prayer-walk-planner instance-id="staging"></prayer-walk-planner>
|
||||
|
||||
<!-- Vite multi-page entry. In dev, served transformed; in build, Vite emits
|
||||
../../../public/doxa-maps/prayer-walk-planner/prayer-walk-planner.js and rewrites this script tag. -->
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
|
|
@ -301,7 +302,7 @@ function staticSnapshotUrl(coords) {
|
|||
return null
|
||||
}
|
||||
|
||||
function printInternal(ids) {
|
||||
async function printInternal(ids) {
|
||||
const targets = ids && ids.length
|
||||
? polygons.filter(p => ids.includes(p.id))
|
||||
: polygons.filter(p => p.selected)
|
||||
|
|
@ -310,59 +311,92 @@ function printInternal(ids) {
|
|||
return { opened: false, count: 0 }
|
||||
}
|
||||
|
||||
const w = window.open('', '_blank', 'width=900,height=1000')
|
||||
if (!w) {
|
||||
statusMsg.value = 'Pop-up blocked — allow pop-ups to print.'
|
||||
return { opened: false, count: 0 }
|
||||
}
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]))
|
||||
|
||||
// One thumbnail per zone, cropped from the LIVE map canvas so it needs no network
|
||||
// request and no key — a snapshot of whatever basemap is currently painted.
|
||||
const thumbs = {}
|
||||
try {
|
||||
if (map.value) {
|
||||
const canvas = map.value.getCanvas()
|
||||
thumbs.shared = canvas.toDataURL('image/png')
|
||||
// ── PER-ZONE POSTER SNAPSHOT (card BUILD-PWP-POSTERS-FROM-THE-SHARED-POSTER-LIBRARY) ──
|
||||
// Driver: "use the same poster generation software that the UPG 100 list used ... for
|
||||
// generating prayer walking maps." The UPG generator (library/scripts/posters/generate.mjs)
|
||||
// is BUILD-TIME over 28 fixed zones; walk zones are drawn at RUNTIME, so this is its runtime
|
||||
// sibling: same design language (portrait sheet, title band, framed map, honest credit --
|
||||
// the 839b0c4 poster rulings), map image sourced from the live keyless MapLibre canvas.
|
||||
// For EACH zone the camera fits that zone's bounds and waits for idle, so the snapshot is a
|
||||
// real WALKING-SCALE street map with the zone polygon painted on it -- not the whole-world
|
||||
// viewport the old single shared snapshot cropped from. The camera is restored afterwards.
|
||||
const shots = {}
|
||||
if (map.value) {
|
||||
const m = map.value
|
||||
const prevCenter = m.getCenter(), prevZoom = m.getZoom()
|
||||
const idle = () => new Promise((res) => { m.once('idle', res); setTimeout(res, 4000) })
|
||||
for (const p of targets) {
|
||||
try {
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity
|
||||
for (const [lng, lat] of p.coords) {
|
||||
if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng
|
||||
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat
|
||||
}
|
||||
m.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 72, duration: 0, maxZoom: 17 })
|
||||
await idle()
|
||||
// ★ CAPTURE INSIDE A RENDER FRAME. MapLibre does not preserve the WebGL drawing buffer
|
||||
// (preserveDrawingBuffer:false), so toDataURL() outside a frame returns a BLANK white
|
||||
// image that still reads as a valid data URL -- measured: the first poster shipped a
|
||||
// white rectangle while hasSnapshot said true. triggerRepaint + once('render') reads
|
||||
// the pixels while the buffer is live; the standard MapLibre snapshot pattern.
|
||||
shots[p.id] = await new Promise((res) => {
|
||||
m.once('render', () => { try { res(m.getCanvas().toDataURL('image/png')) } catch (_) { res(null) } })
|
||||
m.triggerRepaint()
|
||||
})
|
||||
if (!shots[p.id]) delete shots[p.id]
|
||||
} catch (_) { /* tainted/unavailable canvas -- the sheet still prints without the map */ }
|
||||
}
|
||||
} catch (_) { /* canvas may be tainted or unavailable — pages still print without it */ }
|
||||
try { m.jumpTo({ center: prevCenter, zoom: prevZoom }) } catch (_) { /* restore is best-effort */ }
|
||||
}
|
||||
|
||||
const pages = targets.map((p) => {
|
||||
const [clng, clat] = polygonCentroid(p.coords)
|
||||
const pointList = p.coords.slice(0, -1).map(([lng, lat]) => formatCoord(lng, lat)).join(' · ')
|
||||
const pointList = p.coords.slice(0, -1).map(([lng, lat]) => formatCoord(lng, lat)).join(' · ')
|
||||
const img = shots[p.id]
|
||||
return (
|
||||
'<section class="zone-page">' +
|
||||
'<h1>' + esc(p.title) + '</h1>' +
|
||||
'<p class="centroid">Center: ' + formatCoord(clng, clat) + '</p>' +
|
||||
(thumbs.shared ? '<img class="snapshot" src="' + thumbs.shared + '" alt="Map snapshot">' : '') +
|
||||
'<h2>Coordinates</h2>' +
|
||||
'<header class="band"><h1>' + esc(p.title) + '</h1>' +
|
||||
'<p class="kicker">Prayer walking map · centre ' + formatCoord(clng, clat) + '</p></header>' +
|
||||
(img ? '<figure class="mapframe"><img class="snapshot" src="' + img + '" alt="Walking map of ' + esc(p.title) + '"></figure>' : '') +
|
||||
'<p class="charge">Walk the streets of this zone and pray for the people who live, work and worship along them.</p>' +
|
||||
'<h2>Zone corners</h2>' +
|
||||
'<p class="points">' + pointList + '</p>' +
|
||||
'<p class="footer">Prayer Walk Planner · generated ' + esc(new Date().toLocaleDateString()) + '</p>' +
|
||||
'<p class="footer">Prayer Walk Planner · generated ' + esc(new Date().toLocaleDateString()) +
|
||||
' · Map © OpenFreeMap · Data © OpenStreetMap contributors</p>' +
|
||||
'</section>'
|
||||
)
|
||||
}).join('')
|
||||
|
||||
const title = 'Prayer Walking Plan — ' + targets.length + (targets.length === 1 ? ' zone' : ' zones')
|
||||
w.document.open()
|
||||
w.document.write(
|
||||
const w = openPrintFrame(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title>' +
|
||||
'<style>' +
|
||||
'@page{size:auto;margin:14mm}' +
|
||||
// letter portrait explicitly -- size:auto lets the browser pick landscape (the same
|
||||
// defect the poster path fixed in 839b0c4); a walking sheet is a portrait document.
|
||||
'@page{size:letter portrait;margin:12mm}' +
|
||||
'html,body{margin:0;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#3b463d}' +
|
||||
'.zone-page{page-break-after:always;padding:16px 8px;max-width:720px;margin:0 auto}' +
|
||||
'.zone-page{page-break-after:always;max-width:720px;margin:0 auto;padding:10px 8px}' +
|
||||
'.zone-page:last-child{page-break-after:auto}' +
|
||||
'h1{font-size:26px;margin:0 0 6px;border-bottom:3px solid #92b195;padding-bottom:8px}' +
|
||||
'h2{font-size:14px;text-transform:uppercase;letter-spacing:.06em;color:#6d766e;margin:18px 0 6px}' +
|
||||
'.centroid{font-size:14px;color:#6d766e;margin:0 0 14px}' +
|
||||
'.snapshot{width:100%;max-height:360px;object-fit:cover;border:1px solid #d1d4cd;border-radius:4px}' +
|
||||
'.points{font-size:13px;line-height:1.7;word-spacing:2px}' +
|
||||
'.footer{margin-top:22px;font-size:10px;color:#6d766e}' +
|
||||
'</style></head><body>' + pages +
|
||||
'<script>window.onload=function(){setTimeout(function(){window.focus();window.print()},200)}<\/script>' +
|
||||
'</body></html>'
|
||||
// The poster design language: a dark-green title band like the UPG poster header,
|
||||
// the map in a framed figure, quiet uppercase kickers -- material light palette only.
|
||||
'.band{background:#3b463d;color:#fff;border-radius:8px;padding:14px 18px;margin:0 0 12px}' +
|
||||
'h1{font-size:26px;margin:0;letter-spacing:-0.01em}' +
|
||||
'.kicker{margin:4px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#d1d4cd}' +
|
||||
'.mapframe{margin:0;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden}' +
|
||||
'.snapshot{display:block;width:100%;max-height:520px;object-fit:cover}' +
|
||||
'.charge{font-size:14px;line-height:1.6;color:#3b463d;margin:12px 0 0}' +
|
||||
'h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6d766e;margin:16px 0 4px}' +
|
||||
'.points{font-size:12px;line-height:1.7;word-spacing:2px;color:#6d766e}' +
|
||||
'.footer{margin-top:18px;font-size:10px;color:#6d766e;border-top:1px solid #d1d4cd;padding-top:8px}' +
|
||||
'</style></head><body>' + pages + '</body></html>',
|
||||
{ autoprint: true }
|
||||
)
|
||||
w.document.close()
|
||||
if (!w) {
|
||||
statusMsg.value = 'Print frame unavailable in this host.'
|
||||
return { opened: false, count: 0 }
|
||||
}
|
||||
return { opened: true, count: targets.length }
|
||||
}
|
||||
|
||||
|
|
@ -373,7 +407,7 @@ function selectPolygon(id, selected = true) { return togglePolygonSelection(id,
|
|||
function clearSelection() { clearSelectionInternal() }
|
||||
function listPolygons() { return polygons.map(p => ({ ...p })) }
|
||||
function getPolygon(id) { const p = polygons.find(p => p.id === id); return p ? { ...p } : null }
|
||||
function print(ids = null) { return printInternal(ids) }
|
||||
function print(ids = null) { return printInternal(ids) } // async: resolves once the dialog is raised
|
||||
|
||||
defineExpose({
|
||||
addPolygon, deletePolygon, selectPolygon, clearSelection,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Regions — region + country dashboard",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
},
|
||||
"note": "pinHint:false because this page is an EXACT CLONE of DX-life's /regions/<slug> (RegionDetail.vue for a region, CountryDetail.vue for a country) and their page carries no coach-mark over the map. The shared PinHint's 'Tap a coloured dot' overlay is our own chrome; leaving it on would be a visible difference from the page being cloned. Opt-out is the documented channel (library/components/hints/PinHint.vue). Siblings doing the same for their own reason: cities-internet/connectivity-map.meta.json, gospel-resources/resources-map.meta.json."
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@
|
|||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import BarChart from '@map/components/charts/BarChart.vue'
|
||||
import DataTable from '@map/components/tables/DataTable.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import UupgsList from './src/UupgsList.vue'
|
||||
import RegionCountryMap from './src/RegionCountryMap.vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -217,28 +217,10 @@ const tiles = computed(() => {
|
|||
]
|
||||
})
|
||||
|
||||
// The comparison bars: a region's countries against each other, or all regions against each
|
||||
// other when nothing is selected. A country selection keeps its region's bars so the country
|
||||
// stays legible IN CONTEXT rather than as one lone bar.
|
||||
const chartRows = computed(() => {
|
||||
const r = selectedRegion.value
|
||||
const rows = r ? r.countries : regions.value
|
||||
return rows
|
||||
.map((x) => ({
|
||||
label: x.name || x.label || 'Other',
|
||||
value: x.count,
|
||||
// Highlight the selected country inside its region's chart.
|
||||
color: selectedCountry.value && x.slug === selectedCountry.value.slug ? '#3b463d' : '#92b195',
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 24)
|
||||
})
|
||||
|
||||
const chartTitle = computed(() =>
|
||||
selectedRegion.value
|
||||
? `People groups by country — ${selectedRegion.value.label || 'Other'}`
|
||||
: 'People groups by region'
|
||||
)
|
||||
// ★ NO BAR CHART. It was ours, not theirs, and the rule is that a SELECTED region or country
|
||||
// must look IDENTICAL to its DX-life page — RegionDetail.vue has four stat cards, the map, three
|
||||
// goal cards and the country table, and nothing else. The one deliberate difference in this
|
||||
// element is the NO-SELECTION state below, which their site has no route for at all.
|
||||
|
||||
// ── DX-life's RegionDetail, field for field ──────────────────────────────────
|
||||
// Ported from root-dx-life/doxa-life/app/components/public/RegionDetail.vue (read-only clone,
|
||||
|
|
@ -329,6 +311,31 @@ const summaryLine = computed(() => {
|
|||
// regions so the page still compares like with like. The prompt below then says how to narrow it.
|
||||
const isAll = computed(() => subject.value.kind === 'all')
|
||||
|
||||
// ── THE ONE DELIBERATE DIFFERENCE: the no-selection chooser ─────────────────
|
||||
// DX-life has no route for a dashboard without a subject (/regions is the list,
|
||||
// /regions/<slug> always has one, [slug].vue 404s otherwise). Opening tab 2 first is a state
|
||||
// only this element can reach, so it gets the one thing their site cannot give: a GENERIC map
|
||||
// of everything, plus three independent ways to choose a subject — a region dropdown, a country
|
||||
// dropdown, and the all-regions table, whose rows are already clickable. Once a subject is
|
||||
// chosen the page becomes the faithful clone and this chooser disappears.
|
||||
const regionOptions = computed(() =>
|
||||
regions.value.map((r) => ({ value: r.slug, label: r.label || 'Other', count: r.count }))
|
||||
)
|
||||
const countryOptions = computed(() =>
|
||||
regions.value
|
||||
.flatMap((r) => r.countries.map((c) => ({ value: c.slug, label: c.name, count: c.count })))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
)
|
||||
|
||||
function onPickRegion(e) {
|
||||
sel.value = { regionSlug: e?.value || '', countrySlug: '' }
|
||||
}
|
||||
function onPickCountry(e) {
|
||||
const slug = e?.value || ''
|
||||
const owner = regions.value.find((r) => r.countries.some((c) => c.slug === slug))
|
||||
sel.value = { regionSlug: owner?.slug || '', countrySlug: slug }
|
||||
}
|
||||
|
||||
// ── the map's subject: which countries to highlight, and how to frame them ──
|
||||
// DX-life gets centre/zoom/bounds from config/countries-meta.ts, a build-time table we do not
|
||||
// have. Deriving the frame from the FOCUS POINTS themselves is better, not a workaround: their
|
||||
|
|
@ -380,33 +387,9 @@ const mapNote = computed(() => {
|
|||
.replace('%countries%', formatNumber(countries))
|
||||
})
|
||||
|
||||
// ── the country page's people-group list ────────────────────────────────────
|
||||
// CountryDetail.vue:63-69 nests UupgsList locked to the country. That component is 662 lines of
|
||||
// search + eight filter dropdowns + photo cards, and this repo ALREADY ships that element as the
|
||||
// people-groups-grid bundle -- re-implementing it here would be a third copy. So the country page
|
||||
// gets the same INFORMATION through our own table pattern (@map/components/tables/DataTable.vue,
|
||||
// which carries the mobile column-collapse and row-detail behaviour the Driver asked for once
|
||||
// already). Nesting the real people-groups-grid element is the right end state and is a cap-4
|
||||
// composition question, not a styling one.
|
||||
const countryTable = computed(() => {
|
||||
const c = selectedCountry.value
|
||||
if (!c) return null
|
||||
const rows = points.value
|
||||
.filter((p) => p.code === c.code)
|
||||
.sort((a, b) => (b.population - a.population) || a.name.localeCompare(b.name))
|
||||
.map((p) => ({ name: p.name, population: p.population, committed: p.committed }))
|
||||
return {
|
||||
meta: { title: c.name + ' people groups' },
|
||||
columns: [
|
||||
{ field: 'name', title: 'People Group' },
|
||||
{ field: 'population', title: 'Population', hozAlign: 'right', formatter: 'money',
|
||||
formatterParams: { precision: 0, thousand: ',' } },
|
||||
{ field: 'committed', title: 'People Praying', hozAlign: 'right', formatter: 'money',
|
||||
formatterParams: { precision: 0, thousand: ',' } },
|
||||
],
|
||||
rows,
|
||||
}
|
||||
})
|
||||
// The country page's list is DX-life's own UupgsList, ported at src/UupgsList.vue — not a table
|
||||
// of ours. It fetches and filters the people groups itself, exactly as theirs does, so there is
|
||||
// no countryTable computed here any more.
|
||||
|
||||
// ── in-dashboard navigation ─────────────────────────────────────────────────
|
||||
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
|
||||
|
|
@ -551,6 +534,10 @@ useShadowStyles(
|
|||
'.rd-chart{ padding:23.5264px clamp(17.689px, 4cqw, 31.2901px); }' +
|
||||
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
|
||||
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
|
||||
// The chooser's two controls, side by side and wrapping on a phone. Its own block so the
|
||||
// clone below is untouched by it.
|
||||
'.rd-chooser{ display:flex; flex-wrap:wrap; gap:13.3px; align-items:flex-end;' +
|
||||
' justify-content:center; }' +
|
||||
'.rd-mapblock{ display:flex; flex-direction:column; gap:13.3px; }' +
|
||||
'.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:19.5312px; }' +
|
||||
// .rounded-xlg on their <CountryMap>; the child takes border-radius:inherit from here.
|
||||
|
|
@ -676,6 +663,28 @@ useShadowStyles(
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- THE CHOOSER — the no-selection state, and the only thing here DX-life does not have -->
|
||||
<div v-if="isAll" class="rd-chooser">
|
||||
<FilterDropdown
|
||||
label="Region"
|
||||
name="region"
|
||||
:options="regionOptions"
|
||||
value=""
|
||||
placeholder="Type a region…"
|
||||
@filter-change="onPickRegion"
|
||||
@filter-clear="clearSelection"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Country"
|
||||
name="country"
|
||||
:options="countryOptions"
|
||||
value=""
|
||||
placeholder="Type a country…"
|
||||
@filter-change="onPickCountry"
|
||||
@filter-clear="clearSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
|
||||
<div v-if="subject.kind !== 'country'" class="rd-stats">
|
||||
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
|
||||
|
|
@ -701,13 +710,8 @@ useShadowStyles(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ours, not theirs, and kept: the one view that RANKS the subject's parts. -->
|
||||
<div v-if="subject.kind !== 'country'" class="rd-card rd-chart">
|
||||
<BarChart :series="chartRows" :title="chartTitle" unit="people groups" color="#92b195" />
|
||||
</div>
|
||||
|
||||
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
|
||||
<div v-if="subject.kind !== 'country'" class="rd-switcher">
|
||||
<div v-if="subject.kind === 'region'" class="rd-switcher">
|
||||
<div class="rd-card rd-card--secondary rd-goal-card">
|
||||
<h2 class="rd-h2">Prayer Coverage</h2>
|
||||
<div>
|
||||
|
|
@ -777,11 +781,15 @@ useShadowStyles(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CountryDetail.vue:63-69 — UupgsList locked to the country, through our table pattern. -->
|
||||
<div v-if="subject.kind === 'country' && countryTable" class="rd-section">
|
||||
<h2 class="rd-h2 rd-h2--start">People groups in {{ subject.title }}</h2>
|
||||
<DataTable :table="countryTable" :assets-base="assetsBase" />
|
||||
</div>
|
||||
<!-- CountryDetail.vue:63-69 — <UupgsList :lock-country-code hide-see-all-link />, and
|
||||
nothing around it: their page puts no heading above this block. -->
|
||||
<UupgsList
|
||||
v-if="subject.kind === 'country' && selectedCountry"
|
||||
:lock-country-code="selectedCountry.code"
|
||||
:country-name="selectedCountry.name"
|
||||
:pray-base-url="prayBaseUrl"
|
||||
:language-code="languageCode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
8
plugin-designs/regions-dashboard/regions-list.meta.json
Normal file
8
plugin-designs/regions-dashboard/regions-list.meta.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Regions — the list page",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
},
|
||||
"note": "pinHint:false: this profile draws NO map at all (it is the clone of DX-life's /regions index), but it now sits in a bundle that owns a map, so generate-tree classifies it isMap and would bake the coach-mark onto a page with no dots. Same shape as cities-internet/connectivity-map.meta.json."
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
{
|
||||
"type": "nested-elements-mfe",
|
||||
"label": "Regions — List + Dashboards",
|
||||
"composes": ["regions-dashboard/regions-list", "regions-dashboard/region-dashboards"],
|
||||
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2."
|
||||
"composes": [
|
||||
"regions-dashboard/regions-list",
|
||||
"regions-dashboard/region-dashboards"
|
||||
],
|
||||
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2. pinHint:false: the cloned pages carry no coach-mark, and the composite must declare it because a pane's config comes from here, not from the child's own meta.",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,12 @@ const instanceId = computed(() => cfg.value?.instanceId || 'regions-mfe')
|
|||
const spec = computed(() => ({
|
||||
id: 'regions-mfe',
|
||||
tag: TAG,
|
||||
config: { profile: 'regions-list', instanceId: instanceId.value },
|
||||
// pinHint:false reaches the PANES from here, not from their own meta.json. A child profile's
|
||||
// meta only configures its own LEAF page; nested in this composite each pane is handed the
|
||||
// config built below, and PinHint.vue reads pinHint off the INJECTED profileConfig. Without
|
||||
// this the coach-mark reappeared over the cloned map inside the composite while the leaf
|
||||
// pages were correctly clean — the same cap-4 inheritance gap assetsBase hit.
|
||||
config: { profile: 'regions-list', instanceId: instanceId.value, pinHint: false },
|
||||
panes: [
|
||||
{ id: 'list', label: 'Regions List', at: [0, 0], config: { profile: 'regions-list' } },
|
||||
{ id: 'dashboard', label: 'Region Dashboards', at: [1, 0], config: { profile: 'region-dashboards' } },
|
||||
|
|
@ -63,7 +68,7 @@ let offScroll = null
|
|||
// own default. Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
|
||||
// `profile` / `instanceId` cannot be overwritten by the host's — that is what makes a pane a
|
||||
// distinct view at all (pane.config is spread last and always wins).
|
||||
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa']
|
||||
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint']
|
||||
|
||||
function paneElement(pane) {
|
||||
const el = document.createElement(TAG)
|
||||
|
|
|
|||
282
plugin-designs/regions-dashboard/src/UupgsList.vue
Normal file
282
plugin-designs/regions-dashboard/src/UupgsList.vue
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<script setup>
|
||||
/**
|
||||
* UupgsList.vue — DX-life's people-group list, ported for the country page.
|
||||
*
|
||||
* @description CountryDetail.vue:63-69 nests <UupgsList :lock-country-code="country.code"
|
||||
* hide-see-all-link />. That component IS the bottom half of the page you land on when you
|
||||
* click a country on DX-life, so cloning that page means cloning this. Ported from
|
||||
* root-dx-life/doxa-life/app/components/public/UupgsList.vue (read-only clone, standing law),
|
||||
* which is itself a Lit -> Vue port of marketing-theme/js/components/src/uupgs-list.ts and
|
||||
* whose own header says it "preserves class names, markup structure, fetch endpoints, and
|
||||
* filter logic verbatim so the source SCSS applies unchanged".
|
||||
*
|
||||
* WHAT IS CLONED. The markup and the class names, so the ported CSS below is theirs rather than
|
||||
* a lookalike: .filters / .filters__toggle / .filters__panel / .filters__active / .filter-chip,
|
||||
* the .search-box, the .grid[data-width-lg] card grid, and the .uupg__card grid-template-areas
|
||||
* with its 576px reflow. Their endpoint and field list too (UupgsList.vue:388).
|
||||
*
|
||||
* SCOPED TO WHAT THE COUNTRY PAGE ACTUALLY RENDERS, and that is a deliberate narrowing rather
|
||||
* than an omission. Their component carries four call-sites' worth of options — useSelectCard,
|
||||
* useHighlightedUUPGs, randomizeList, dontShowListOnLoad, a select-card variant with prayer
|
||||
* counters, the /highlighted endpoint, the see-all link. CountryDetail passes NONE of them: it
|
||||
* passes a locked country and hide-see-all-link, which selects the DEFAULT full card and hides
|
||||
* the see-all. Porting the branches the page cannot reach would be code no consumer runs.
|
||||
*
|
||||
* NOT CLONED, and it is visible: their cards show a photo per people group from image_url, and
|
||||
* the /api/people-groups/list response we consume carries has_photo + image_url, so the img is
|
||||
* wired. Where a group has no photo the card reflows without one, as theirs does.
|
||||
*/
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
|
||||
const props = defineProps({
|
||||
// ISO-3. Their `lockCountryCode`: the country filter is pre-applied and its dropdown hidden.
|
||||
lockCountryCode: { type: String, default: '' },
|
||||
countryName: { type: String, default: '' },
|
||||
prayBaseUrl: { type: String, default: 'https://pray.doxa.life' },
|
||||
languageCode: { type: String, default: '' },
|
||||
researchUrl: { type: String, default: 'https://doxa.life/research/' },
|
||||
// Their perPage / morePerPage defaults (UupgsList.vue props block).
|
||||
perPage: { type: Number, default: 24 },
|
||||
morePerPage: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
// Their field list, verbatim (UupgsList.vue:388).
|
||||
const FIELDS = 'name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,location_description'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const filtersExpanded = ref(false)
|
||||
const shown = ref(props.perPage)
|
||||
// Their filter set, minus `country` — a locked country hides that one (their own rule).
|
||||
const active = ref({ wagf_region: null, wagf_block: null, rop1: null, religion: null })
|
||||
|
||||
const label = (f) => (f && typeof f === 'object' ? f.label : f) || ''
|
||||
const value = (f) => (f && typeof f === 'object' ? f.value : f) || ''
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const r = await fetch(`${props.prayBaseUrl}/api/people-groups/list?fields=${FIELDS}&lang=${props.languageCode}`)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const d = await r.json()
|
||||
const all = Array.isArray(d.posts) ? d.posts : []
|
||||
rows.value = props.lockCountryCode
|
||||
? all.filter((p) => value(p.country_code) === props.lockCountryCode)
|
||||
: all
|
||||
} catch (e) {
|
||||
console.error('[UupgsList] fetch error:', e)
|
||||
failed.value = true
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
watch(() => props.lockCountryCode, load)
|
||||
|
||||
// Distinct values per filter key, with counts — the shape FilterDropdown wants.
|
||||
function optionsFor(key) {
|
||||
const m = new Map()
|
||||
for (const p of rows.value) {
|
||||
const v = value(p[key]); const l = label(p[key])
|
||||
if (!v) continue
|
||||
const e = m.get(v) || { value: v, label: l || v, count: 0 }
|
||||
e.count++; m.set(v, e)
|
||||
}
|
||||
return [...m.values()].sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
const filterDefs = computed(() => [
|
||||
{ name: 'wagf_region', label: 'WAGF Region' },
|
||||
{ name: 'wagf_block', label: 'WAGF Block' },
|
||||
{ name: 'rop1', label: 'Affinity Bloc' },
|
||||
{ name: 'religion', label: 'Religion' },
|
||||
].map((f) => ({ ...f, options: optionsFor(f.name) })))
|
||||
|
||||
const activeChips = computed(() =>
|
||||
Object.entries(active.value).filter(([, v]) => v).map(([name, v]) => ({ name, ...v }))
|
||||
)
|
||||
|
||||
// Their filter+search pipeline: every active filter ANDs, then the term matches a name or an
|
||||
// alternate name. Substring, not fuzzy — the fuzzy ladder is the /research page's own concern.
|
||||
const filtered = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
return rows.value.filter((p) => {
|
||||
for (const [k, v] of Object.entries(active.value)) {
|
||||
if (v && value(p[k]) !== v.value) return false
|
||||
}
|
||||
if (!term) return true
|
||||
const alt = p.imb_alternate_name || ''
|
||||
return String(p.name || '').toLowerCase().includes(term) || String(alt).toLowerCase().includes(term)
|
||||
})
|
||||
})
|
||||
const displayed = computed(() => filtered.value.slice(0, shown.value))
|
||||
const showMore = computed(() => filtered.value.length > shown.value)
|
||||
function loadMore() { shown.value += (props.morePerPage || props.perPage) }
|
||||
watch([searchTerm, active], () => { shown.value = props.perPage }, { deep: true })
|
||||
|
||||
function onFilterChange(e) { if (e && e.name) active.value = { ...active.value, [e.name]: { value: e.value, label: e.label } } }
|
||||
function onFilterClear(e) { if (e && e.name) active.value = { ...active.value, [e.name]: null } }
|
||||
function clearAll() { active.value = { wagf_region: null, wagf_block: null, rop1: null, religion: null } }
|
||||
function profileUrl(p) { return `${props.researchUrl}${p.slug || ''}` }
|
||||
|
||||
// Every value resolved against DX-life's 10px rem root, from the same token files as the rest of
|
||||
// this bundle. Sources: blocks/_filters.scss (.filters*, .filter-chip*), blocks/_form.scss
|
||||
// (.search-box, input[type=search]), blocks/_cards.scss:143-236 (.uupg__*), blocks/_buttons.scss
|
||||
// (.button, .button.compact), composition/_grid.scss (.grid + data-width-lg).
|
||||
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
|
||||
useShadowStyles(
|
||||
':host{ display:block; }' +
|
||||
'.ul-root{ display:flex; flex-direction:column; gap:17.689px;' +
|
||||
' --dx-text:#2a332a; --dx-muted:#6d766e; --dx-white:#fff; color:var(--dx-text);' +
|
||||
' font:19.5312px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
|
||||
// .filters — a .stack--md column
|
||||
'.filters{ display:flex; flex-direction:column; gap:17.689px; }' +
|
||||
// .search-box + input[type=search]: 100% wide, pill, text indented past the icon
|
||||
'.search-box{ position:relative; width:100%; max-width:480px; margin-inline:auto; }' +
|
||||
'.search-box input{ box-sizing:border-box; width:100%; border:1px solid #666; border-radius:100px;' +
|
||||
' background:var(--dx-white); padding:10px 23.5264px; text-indent:1.5em; font:inherit;' +
|
||||
' font-size:19.5312px; color:var(--dx-text); }' +
|
||||
'.search-box input::placeholder{ color:gray; }' +
|
||||
'.search-icon{ position:absolute; left:16px; top:50%; transform:translateY(-50%); width:18px;' +
|
||||
' height:18px; fill:none; stroke:var(--dx-muted); stroke-width:2; pointer-events:none; }' +
|
||||
// .filters__toggle — centred, chevron rotates when expanded
|
||||
'.filters__toggle{ background:none; border:0; cursor:pointer; font:inherit; font-size:15.625px;' +
|
||||
' display:inline-flex; align-items:center; gap:7.5188px; margin-inline:auto; color:var(--dx-text); }' +
|
||||
'.filters__toggle svg{ transition:transform 200ms ease; }' +
|
||||
'.filters__toggle[aria-expanded="true"] svg{ transform:rotate(180deg); }' +
|
||||
'.filters__panel{ display:flex; flex-wrap:wrap; gap:13.3px; justify-content:center;' +
|
||||
' align-items:flex-start; }' +
|
||||
'.filters__active{ display:flex; flex-wrap:wrap; gap:7.5188px; align-items:center;' +
|
||||
' justify-content:center; }' +
|
||||
// .filter-chip — surface-brand-lighter on white, fully round
|
||||
'.filter-chip{ display:inline-flex; gap:7.5188px; background:#6d766e; color:#fff;' +
|
||||
' border-radius:100px; padding:5.6532px 13.3px; font-size:15.625px; text-align:center; }' +
|
||||
'.filter-chip__remove{ background:none; border:0; cursor:pointer; font-size:1.1em; line-height:1;' +
|
||||
' color:inherit; padding:0; display:inline-flex; align-items:center; }' +
|
||||
'.filter-chip__remove:hover{ opacity:0.7; }' +
|
||||
'.filters__clear-all{ background:none; border:0; cursor:pointer; font:inherit; font-size:15.625px;' +
|
||||
' color:var(--dx-text); text-decoration:underline; text-underline-offset:4px;' +
|
||||
' text-decoration-thickness:2px; }' +
|
||||
'.ul-count{ text-align:center; font-size:15.625px; color:var(--dx-muted); }' +
|
||||
// .grid[data-width-lg] — auto-fit, 400px min track, --spacing-2xl gap
|
||||
'.grid{ display:grid; grid-gap:41.6158px;' +
|
||||
' grid-template-columns:repeat(auto-fit, minmax(min(400px,100%), 1fr)); }' +
|
||||
// .card + .uupg__card: their grid-template-areas, radius-xlg, --spacing-md padding + gap
|
||||
'.uupg__card{ box-sizing:border-box; background:var(--dx-white); color:var(--dx-text);' +
|
||||
' border-radius:35px; display:grid;' +
|
||||
' grid-template-areas:"image header adopted" "image content content" "footer footer footer-right";' +
|
||||
' grid-template-columns:auto 1fr auto; grid-template-rows:auto auto auto;' +
|
||||
' gap:17.689px; padding:17.689px; }' +
|
||||
'.uupg__image{ width:100px; height:100px; object-fit:cover; grid-area:image; border-radius:7.5px;' +
|
||||
' background:#f3f3f1; }' +
|
||||
'.uupg__header{ grid-area:header; }' +
|
||||
'.uupg__name{ margin:0; font-family:inherit; font-size:24.4141px; font-weight:500;' +
|
||||
' line-height:1.25; }' +
|
||||
'.uupg__country{ margin:0; font-size:19.5312px; }' +
|
||||
'.uupg__country-link{ color:#4e594f; text-decoration:none; }' +
|
||||
'.uupg__country-link:hover{ text-decoration:underline; }' +
|
||||
// theirs is display:none — the location line is fetched and deliberately not shown
|
||||
'.uupg__content{ display:none; grid-area:content; }' +
|
||||
// .button.compact, grid-area footer, bottom-aligned
|
||||
'.uupg__more-button{ grid-area:footer; align-self:flex-end; display:block; width:fit-content;' +
|
||||
' box-sizing:border-box; padding:10px 41.6158px; color:#f3f3f1; background:#92b195;' +
|
||||
' border-radius:100px; border:1px solid transparent; line-height:1; text-decoration:none;' +
|
||||
' text-transform:uppercase; font-size:19.5312px;' +
|
||||
' font-family:"Brandon Grotesque",Poppins,-apple-system,"Segoe UI",sans-serif;' +
|
||||
' transition:background-color .3s ease, color .3s ease; cursor:pointer; }' +
|
||||
'.uupg__more-button:hover{ background:#2a332a; color:#f3f3f1; }' +
|
||||
// their 576px reflow: the photo and the header stop sharing a row
|
||||
'@media (max-width:576px){ .uupg__card{' +
|
||||
' grid-template-areas:"image adopted" "header header" "content content" "footer footer";' +
|
||||
' grid-template-columns:auto 1fr; grid-template-rows:auto auto auto auto; } }' +
|
||||
'.loading, .ul-status{ text-align:center; color:var(--dx-muted); font-size:19.5312px; padding:17.689px; }' +
|
||||
'.ul-status.ul-failed{ font-weight:600; }' +
|
||||
// .center | .button compact .stack-spacing-2xl
|
||||
'.ul-more{ margin-inline:auto; margin-top:41.6158px; display:block; width:fit-content;' +
|
||||
' padding:10px 41.6158px; color:#f3f3f1; background:#92b195; border-radius:100px;' +
|
||||
' border:1px solid transparent; line-height:1; text-transform:uppercase; font-size:19.5312px;' +
|
||||
' font-family:"Brandon Grotesque",Poppins,-apple-system,"Segoe UI",sans-serif; cursor:pointer; }' +
|
||||
'.ul-more:hover{ background:#2a332a; }',
|
||||
'uupgs-list'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ul-root">
|
||||
<div class="filters">
|
||||
<div class="search-box">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<label class="sr-only" :for="'ul-search'">Search</label>
|
||||
<input id="ul-search" v-model="searchTerm" type="search" placeholder="Search" aria-label="Search people groups">
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="filters__toggle"
|
||||
:aria-expanded="filtersExpanded ? 'true' : 'false'"
|
||||
@click="filtersExpanded = !filtersExpanded"
|
||||
>
|
||||
{{ filtersExpanded ? 'Hide Filters' : 'Show Filters' }}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="filtersExpanded" class="filters__panel">
|
||||
<FilterDropdown
|
||||
v-for="f in filterDefs"
|
||||
:key="f.name"
|
||||
:label="f.label"
|
||||
:name="f.name"
|
||||
:options="f.options"
|
||||
:value="active[f.name]?.value || ''"
|
||||
placeholder="Type to search…"
|
||||
@filter-change="onFilterChange"
|
||||
@filter-clear="onFilterClear"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeChips.length" class="filters__active">
|
||||
<span v-for="c in activeChips" :key="c.name" class="filter-chip">
|
||||
{{ c.label }}
|
||||
<button type="button" class="filter-chip__remove" :aria-label="'Remove ' + c.label" @click="onFilterClear({ name: c.name })">×</button>
|
||||
</span>
|
||||
<button type="button" class="filters__clear-all" @click="clearAll">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!loading && !failed" class="ul-count">
|
||||
{{ filtered.length.toLocaleString('en-US') }} people groups<span v-if="countryName"> in {{ countryName }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="failed" class="ul-status ul-failed" role="status">
|
||||
People groups could not be loaded. This is a connection problem, not a filter.
|
||||
</div>
|
||||
|
||||
<div id="results" class="grid" data-width-lg>
|
||||
<div v-for="p in displayed" :key="p.slug" class="uupg__card">
|
||||
<img v-if="p.image_url" class="uupg__image" :src="p.image_url" :alt="p.name" loading="lazy">
|
||||
<div class="uupg__header">
|
||||
<h3 class="uupg__name">{{ p.name }}</h3>
|
||||
<p class="uupg__country">
|
||||
<a class="uupg__country-link" :href="profileUrl(p)">{{ label(p.country_code) }}</a>
|
||||
<span v-if="label(p.rop1)"> ({{ label(p.rop1) }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="p.location_description" class="uupg__content">{{ p.location_description }}</p>
|
||||
<a class="uupg__more-button" :href="profileUrl(p)">Full profile</a>
|
||||
</div>
|
||||
<div v-if="loading" class="loading">Loading…</div>
|
||||
</div>
|
||||
|
||||
<button v-if="showMore" type="button" class="ul-more" @click="loadMore">Load more</button>
|
||||
</div>
|
||||
</template>
|
||||
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
12
public/doxa-maps/manifest.json
generated
12
public/doxa-maps/manifest.json
generated
|
|
@ -813,7 +813,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-l3nBNqhx1fG3fiLlkCtbIf5R66cNHhZfKs1nIsfznYgpcBsSfG+vUqjAzloqqEwk",
|
||||
"integrity": "sha384-OTTFfQpIOOhwH3QUT2Mg2GN32VxfytqbJz9E4jwV48oMXe2iDp+x9EAMr7CDEr2C",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -829,7 +829,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-l3nBNqhx1fG3fiLlkCtbIf5R66cNHhZfKs1nIsfznYgpcBsSfG+vUqjAzloqqEwk\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-OTTFfQpIOOhwH3QUT2Mg2GN32VxfytqbJz9E4jwV48oMXe2iDp+x9EAMr7CDEr2C\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -838,7 +838,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR",
|
||||
"integrity": "sha384-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -858,7 +858,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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR\" 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-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -872,7 +872,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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR\" 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-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -886,7 +886,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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR\" 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-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9\" 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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
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 <prayer-walk-planner> custom element. -->
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-l3nBNqhx1fG3fiLlkCtbIf5R66cNHhZfKs1nIsfznYgpcBsSfG+vUqjAzloqqEwk" crossorigin="anonymous"></script>
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-OTTFfQpIOOhwH3QUT2Mg2GN32VxfytqbJz9E4jwV48oMXe2iDp+x9EAMr7CDEr2C" 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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"pinHint":false,"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
|
|
|
|||
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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"regions-list","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-list","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"pinHint":false,"profile":"regions-list","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-list","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
|
|
|
|||
|
|
@ -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-WvWmE3YlemzQpGOlRSTFZurwYkIbPGz5GoXBMeHtmXgm4P1H/n+2wdGzjgXFQtxR" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-TsByiuoI9fzzdRROm6GocsQuBAYAJld4MuhMZlSLw0Y6Crnzqyoy8I+V15j8RTB9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"pinHint":false,"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
|
|
|
|||
Loading…
Reference in a new issue