Brand-neutral shared VerticalTabsLayout with lazy nested-element hosting; fix cap-5 prayer tab leaf path; neutralize library identifiers; add Forgejo CI build-gate workflow
Some checks failed
build-gate / build (push) Has been cancelled
120
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# build.yml — the SINGLE-BUILDER CI gate for the doxa-maps bundler.
|
||||
#
|
||||
# WHY THIS EXISTS (IDEA-FORGEJO-CICD-BUILD-LOCK-MULTIWRITER):
|
||||
# Many agents edit + commit source to this repo in PARALLEL. That is safe. What is
|
||||
# NOT safe is many agents each running `bun run build` into the shared, committed
|
||||
# web-root (public/): concurrent builds clobber each other's output and race the
|
||||
# `git add public/` that follows. internal/build-lock.sh makes that STRUCTURALLY
|
||||
# impossible on ONE machine (an flock mutex); this workflow does the same thing at
|
||||
# CLUSTER scope — it makes CI the ONE-AND-ONLY builder, and serializes it.
|
||||
#
|
||||
# THE CONTRACT for a swarm agent:
|
||||
# edit files -> git commit -> git push (DONE. never run `bun run build`.)
|
||||
# The push lands here. This job — and ONLY this job — runs the authoritative build
|
||||
# and commits the regenerated public/ back. See the handoff doc:
|
||||
# _qa-cycle/03-deployment-cycle/delegation/HANDOFF-forgejo-cicd-buildgate.md
|
||||
#
|
||||
# NOTE ON PATH: Forgejo reads .forgejo/workflows/ (and .gitea/workflows/), NOT
|
||||
# .github/workflows/. The existing .github/workflows/pages.yml is a GitHub-Pages
|
||||
# workflow and never runs here; this is its Forgejo graduation.
|
||||
|
||||
name: build-gate
|
||||
|
||||
# Trigger on every push to the source branches. workflow_dispatch lets the Driver
|
||||
# run it by hand from the Forgejo UI.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- doxa-maps-plugin-studio
|
||||
# Source-only edits trigger a build. Skip pushes that touch NOTHING but the
|
||||
# build output or CI itself — the build-output commit this job makes would
|
||||
# otherwise re-trigger the job in a loop.
|
||||
paths-ignore:
|
||||
- 'public/**'
|
||||
- '.forgejo/**'
|
||||
workflow_dispatch:
|
||||
|
||||
# THE HEART OF THE GATE. All builds share ONE concurrency group, so Forgejo runs
|
||||
# at most one at a time. A push that arrives while a build is running is QUEUED,
|
||||
# not cancelled and not failed — cancel-in-progress is FALSE on purpose: we want
|
||||
# every push's source to be built in order, never dropped. This is the flock
|
||||
# nonblock/queue behaviour of build-lock.sh, expressed as a scheduler primitive.
|
||||
concurrency:
|
||||
group: doxa-maps-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Requires a registered Forgejo Actions runner labelled `ubuntu-latest`
|
||||
# (docker or host). If none is registered the job stays queued — see the
|
||||
# handoff doc for the ONE Driver action that registers it.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# The job needs to read the repo AND commit the regenerated public/ back, so
|
||||
# it needs write. The runner's automatic token carries the repo's scope.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout the pushed source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so the build-output commit is a normal fast-forward.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install bun
|
||||
uses: https://github.com/oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies (locked)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# THE AUTHORITATIVE BUILD. build:unlocked is the real chain (clean -> token
|
||||
# -> per-bundle vite -> tree -> drop/parse checks -> verify -> gallery). We
|
||||
# call :unlocked, NOT `bun run build`, because CI IS the single builder now —
|
||||
# the local flock mutex would only get in its own way here. The MAPBOX_TOKEN
|
||||
# secret becomes public/api/maps/token.json (gitignored) so the credential
|
||||
# reaches the built site without ever entering git history.
|
||||
- name: Build (authoritative, single-writer)
|
||||
env:
|
||||
MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }}
|
||||
VITE_MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }}
|
||||
BUILD_LOCK_OWNER: forgejo-ci
|
||||
run: bun run build:unlocked
|
||||
|
||||
# Guards graduated from pages.yml: never publish a broken or credential-
|
||||
# leaking web-root. A failure here fails the build — nothing is committed.
|
||||
- name: Assert the token endpoint exists
|
||||
run: |
|
||||
test -s public/api/maps/token.json || {
|
||||
echo "::error::public/api/maps/token.json is missing/empty — set the MAPBOX_TOKEN secret, or every basemap renders blank."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Assert no local credential is being published
|
||||
run: |
|
||||
if [ -e public/token.local.js ]; then
|
||||
echo "::error::public/token.local.js is present in the web-root and must not be committed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Commit the regenerated public/ back to the branch. This is the ONE writer
|
||||
# of build output in the whole system. If the build produced no change,
|
||||
# commit nothing (idempotent). The token endpoint is gitignored, so it is
|
||||
# never staged. The [skip ci]-equivalent is handled by the paths-ignore
|
||||
# above: a public/-only commit does not re-trigger this job.
|
||||
- name: Publish the built web-root
|
||||
run: |
|
||||
git config user.name "forgejo-ci"
|
||||
git config user.email "ci@doxa.life"
|
||||
git add public/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No build-output change — nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "build: regenerate public/ from ${GITHUB_SHA:-source}"
|
||||
# Push back to the branch that triggered us. The runner token authorizes
|
||||
# this; contents:write is granted above.
|
||||
git push origin "HEAD:${GITHUB_REF_NAME}"
|
||||
404
README.md
|
|
@ -1,305 +1,169 @@
|
|||
# doxa-maps — prayer maps that embed everywhere
|
||||
# doxa-maps — one build, maps that embed everywhere
|
||||
|
||||
> **Bottom line up front.** A global prayer movement stalls when there is only **one** website where people
|
||||
> can see the task and sign up — today that plateau sits at roughly 600–1,000 people groups covered. The fix is
|
||||
> not a better single website. It is a map that lives on **thousands** of partner-team websites at once, shows
|
||||
> the prayer task clearly, changes as people sign up, and carries a **Share** button so every team that receives
|
||||
> it can pass it on. This bundler builds that map — and every other map you need — as one embeddable file each.
|
||||
**A Vite bundler that turns each map into a self-contained web component.** One `bun run build` produces a
|
||||
`doxa-maps/` folder you drop on any host — iframe it, script-embed it, ship it as a mobile app, or open the
|
||||
whole thing as one SPA. **Same code, every screen.**
|
||||
|
||||
## Why embed, not link
|
||||
|
||||
One sign-up site is a bottleneck. A map embedded on a partner team's *own* site multiplies three surfaces at
|
||||
once: the **prayer surface** (more people see the task), the **information surface** and the **understanding
|
||||
surface**. And a team that has the map on its own site treats it as its own — ownership becomes stewardship.
|
||||
Sign-up can still happen in one place; the *map* does not have to.
|
||||
|
||||
## What this trains you to be (Training for Trainers)
|
||||
|
||||
We borrow the language of **Training for Trainers (T4T)** — Ying Kai's method, told in *T4T: A Discipleship
|
||||
Re-Revolution* (Steve Smith with Ying Kai, 2011; trainer resources at [t4tonline.org](https://t4tonline.org)):
|
||||
the goal is never one trained person, but trainers who train trainers. This README trains you to be three things:
|
||||
|
||||
1. a **map maker** — you (with your AI agent) build maps on the shared library in this repo;
|
||||
2. a **map multiplier** — every map you build embeds anywhere and carries a Share button;
|
||||
3. a **prayer mobilizer** — you run the campaign that multiplies the surface: the map onto the website of
|
||||
every team you know that is trying to multiply disciples among the unreached, and each of them trained to
|
||||
pass it on with the same button — **[the Playbook](#the-playbook--start-a-prayer-movement-with-this-map)**.
|
||||
|
||||
Everything below is for the people who build the maps. If you only want to *run the campaign*, jump to the
|
||||
Playbook; the full walkthrough is [docs/tutorial.md](./docs/tutorial.md).
|
||||
This README is a **show, don't tell** index. Skim the three tables, then jump to the section you need.
|
||||
|
||||
---
|
||||
|
||||
## What the bundler is — a JAMstack map bundler for vibe coders
|
||||
## Index
|
||||
|
||||
> **Built by a vibe coder, for vibe coders** — and for map multipliers and prayer mobilizers who
|
||||
> want every people, in every place, prayed for by name.
|
||||
### The 5 plugin (app) types — from simplest to full app
|
||||
|
||||
**One build turns every map into a self‑contained web component** that embeds anywhere an embed
|
||||
script or URL works, and every map carries the **Share button** so any team can pass its map to the
|
||||
next team. **The seemingly impossible part:** the same build also composes all your maps, tables, and
|
||||
dashboards into a single‑page application that runs as a website, installs as a mobile app, and
|
||||
embeds on partner sites piece by piece. *Same code, every screen.*
|
||||
| # | Capability | What it is | Example plugin | Section |
|
||||
|---|------------|------------|----------------|---------|
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. The lightest tier. | `doxa-vision-card` | [§1](#cap-1--single-element) |
|
||||
| 2 | **Preset element (prop-table)** | ONE `root.vue` rendered N ways by preset props — one plugin, many maps. | `doxa-simple-map` (prayer · engagement · adoption) | [§1](#cap-2--preset-element-prop-table) |
|
||||
| 3 | **Monolithic / multi-view** | A whole research map, all in one `root.vue`. | `doxa-research-map` | [§1](#cap-3--monolithic-research-map) |
|
||||
| 4 | **Composite element** (nested-element SPA) | A composite element that nests other elements and wires them via props/events. | `my-upg-100-list` | [§1](#cap-4--composite-element) |
|
||||
| 5 | **App shell** (nested-SPA) | The full doxa-maps app — every plugin in one deep-linkable page. | the doxa-maps SPA | [§1](#cap-5--app-shell--the-doxa-maps-app) |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/spa-desktop.png" alt="The composed SPA on desktop — many maps, tables and dashboards in one app" width="680">
|
||||
<img src="docs/assets/spa-mobile.png" alt="The same SPA, same code, as a phone app" width="170">
|
||||
</p>
|
||||
<p align="center"><em>One build, same code: the SPA in a desktop browser and on a phone — many maps, one place.</em></p>
|
||||
### The 5 deployment methods
|
||||
|
||||
- **One repo, one library** — every map shares components. Not fifty folders of duplicated spaghetti.
|
||||
- **`bun run dev`** → a live plugin gallery of every map, each with a copy‑paste embed snippet.
|
||||
- **`bun run build`** → every map ships as its own self‑contained embeddable file (script tag or iframe — WordPress included), and the same build feeds the SPA and the mobile app.
|
||||
| # | Method | Status | Section |
|
||||
|---|--------|--------|---------|
|
||||
| 1 | **iframe embed** on any site | required | [§2](#deploy-1--iframe-embed) |
|
||||
| 2 | **Mobile app** (Android / Capacitor) | required | [§2](#deploy-2--mobile-app) |
|
||||
| 3 | **Direct-JS / research dashboard** (props + events + SRI) | required | [§2](#deploy-3--research-dashboard-direct-js) |
|
||||
| 4 | **WordPress** (same artifacts, Custom HTML block) | deferred POC | [§3](#3-cli-commands--installs-per-deploy-method) |
|
||||
| 5 | **Desktop** (Tauri) | deferred POC | [§3](#3-cli-commands--installs-per-deploy-method) |
|
||||
|
||||
**You don't write the code.** Point your AI agent at the example maps, describe what you want in plain English, and it builds a new one on the shared library.
|
||||
### CLI commands + what to install
|
||||
|
||||
> ⚠️ **Maps ship without data** — being JAMstack (pure JS), they fetch it (like people groups) from an **API** at runtime ([details](#the-data-caveat)).
|
||||
|
||||
---
|
||||
|
||||
## Have you ever dreamed of making maps?
|
||||
|
||||
Here's the story. Maybe it's yours too.
|
||||
|
||||
You got into building software right as AI made it possible for anyone to write code. And you had a
|
||||
vision: **make maps** — every map you could imagine — to advance the Kingdom of God, so that every people
|
||||
in every place could hear the gospel of Jesus.
|
||||
|
||||
So you started. And it worked… for one map. AI is *great* at writing a single map — a couple thousand
|
||||
lines of code you can embed on a website.
|
||||
|
||||
But then you wanted a second map. And a fifth. And a fiftieth. And here's the wall you hit:
|
||||
|
||||
- Every new map meant **copying the whole thing** into a new folder — nothing was reusable.
|
||||
- Your maps grew past 2,000… 5,000… lines of tangled "spaghetti code."
|
||||
- You ended up with **50 folders, 50 map projects**, scattered across your machine — half of them lost.
|
||||
- Each one was fragile, glitchy, and difficult to maintain, and harder to form a community around.
|
||||
- You tried to build the bigger platform — many maps in one app — and the agent got stuck past map one. Somewhere in there, the mobile‑app dream quietly went out the window.
|
||||
|
||||
**If that's you: you built maps for Jesus. I salute you.** Those maps have their place. But you were
|
||||
missing one thing — a *framework* that lets all your maps live in one place and share reusable pieces.
|
||||
|
||||
That's what this is. Built for communities of map makers who want to make open source maps that can multiply everywhere. And the dream you threw out the window? The SPA here runs on your phone **by design** — same code.
|
||||
|
||||
---
|
||||
|
||||
## Key terms (don't be afraid of these — you can learn each in one search)
|
||||
|
||||
New to this? Read this table first. You do **not** need to memorize it — just know the words exist and where to learn them.
|
||||
|
||||
| Term | In plain words | Learn more |
|
||||
|---|---|---|
|
||||
| **Vibe coder** | Someone who builds with AI writing most of the code. That's who this is for. | — |
|
||||
| **JAMstack** | A way of building where the front end is just **J**avaScript, **A**PIs, and **M**arkup (HTML/CSS). It runs almost anywhere — including WordPress. | [jamstack.org](https://jamstack.org) |
|
||||
| **Bundler** | A tool that takes all your code and packs it into a few ready‑to‑ship files. This repo *is* a bundler for maps. | [vite.dev/guide](https://vite.dev/guide/) |
|
||||
| **Build step** | The one command (`bun run build`) that turns your code into those shippable files. Your agent can run it for you. | — |
|
||||
| **bun** | A fast, all‑in‑one replacement for `npm` (installs packages, runs scripts). We use it here. | [bun.sh](https://bun.sh) |
|
||||
| **node modules** | The reusable code packages your project depends on. If you use VUE, and VITE you need Node Modules. It is like installing libraries and dependencies that allow VUE to work. `bun install` downloads them into your doxa-maps folder on your laptop `node_modules/`. | [bun.sh/docs](https://bun.sh/docs/cli/install) |
|
||||
| **Dev server** | A local preview website (run this command 'bun run dev' after 'bun install') that updates live as you edit. You run it, it let's you see all the maps in one place so you can give your agents feedback, and see changes almost real time when the agent makes edits. It runs at `http://localhost:5173`. | — |
|
||||
| **Vue / React** | Popular, well‑supported frameworks for building front ends. This bundler is built on **Vue** + **Vite** — commit to learning Vue (if you know React, the concepts transfer). | [vuejs.org](https://vuejs.org) |
|
||||
| **Web component** | A custom HTML tag (like `<doxa-map>`) that works on any page, no framework required. Each of your maps becomes one. | [MDN: Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) |
|
||||
| **iframe** | A safe window that embeds one site inside another. Simple, isolated. Each map comes with iframe support by default | [MDN: iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) |
|
||||
| **Shadow DOM** | Lets your map's JavaScript run *directly* inside the host page (not walled off in an iframe) so it can integrate deeply — while keeping its styles from clashing. | [MDN: Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) |
|
||||
| **SPA (single‑page application)** | One page that composes several maps together in a layout — a whole multi-map site from one build. | [MDN: SPA](https://developer.mozilla.org/en-US/docs/Glossary/SPA) |
|
||||
| **CDN** | A URL that serves a file (JavaScript/CSS) to be fetched and run in a browser. When you use Mapbox, you're loading its CDN. Your built maps are served the same way. JAMstack allows these map frontends to run through a CDN, yes they are that modular! Or, you can run them directly in your site, but CDN allows them to embed via iframe onto partner websites easily with a single URL | [MDN: CDN](https://developer.mozilla.org/en-US/docs/Glossary/CDN) |
|
||||
| **Minify** | The build shrinks your code into compact, hard‑to‑read (but still working) form — smaller and harder to copy. | [MDN: Minification](https://developer.mozilla.org/en-US/docs/Glossary/Minification) |
|
||||
| **Capacitor** | Wraps your maps app into a real iOS/Android apps, or make a SPA ('single-page application' see above) by joining multiple maps together into a layout, and bundling that SPA into IOS/Android easily — **same code, no Swift, no Flutter, no React Native.** (The Android project ships in this repo; iOS scaffolds the same way on a Mac.) | [capacitorjs.com](https://capacitorjs.com) |
|
||||
| **Lazy loading** | Only loads each map when it's actually needed, so a page with many maps stays fast. | [web.dev: lazy loading](https://web.dev/articles/lazy-loading) |
|
||||
|
||||
---
|
||||
|
||||
## What doxa-maps gives you
|
||||
|
||||
- **One repository for all your maps.** Each map is an *application profile* in `plugin-designs/<your-map>/`.
|
||||
- **A shared library of reusable, parameterized components** (`library/` — the map, the legend, the
|
||||
controls, the search bar) so your maps stop duplicating code.
|
||||
- **Room for each map's own custom code** — because real maps always have something unique.
|
||||
- **Rapid prototyping.** Add a `.vue` app-profile file, rebuild, and it's live. Build *hundreds* of maps from one library.
|
||||
- **Your agent's happy place.** Everything's in one folder, so your AI agent has all the context it needs to
|
||||
build the next map from the examples already here. Give your agent the proper context and it will have what it needs to make new maps. The only limit is our fear of new architecture, we can learn JAMstack, and see the use cases, and grow in a new framework of software design that is appropriate for open source maps that need to embed everywhere.
|
||||
- **The Great Commission's Maps** Everyone needs to work together to fulfill the great commission, and it will not be possible without maps. Allowing map communities to multiply open source maps that can embed onto all partner sites means doxa-maps is built to solve global mobilization problems, not just a map for centralized website.
|
||||
|
||||
---
|
||||
|
||||
## The parts that make you do a backflip
|
||||
|
||||
- **One build → many embeddable maps.** `bun run build` turns every map into a self‑contained web component
|
||||
*and* generates a standalone HTML page for each one.
|
||||
- **Embed anywhere.** Drop a map on your own site, a partner team's site, or WordPress — via an **iframe**
|
||||
(safe and isolated) *or* via **shadow DOM** (the map's JavaScript runs directly in the page, for deep
|
||||
integration and advanced features).
|
||||
- **Compose maps into a single‑page app.** Put several maps into one layout — a whole multi‑map site — and it
|
||||
**lazy‑loads** each map so it stays fast even when there are many.
|
||||
- **Turn it into a mobile app.** The same code runs as an **iOS/Android app** via **Capacitor** — no new
|
||||
language to learn. Design it mobile‑friendly right in your browser (inspect element → switch to a phone
|
||||
aspect ratio), then wrap it.
|
||||
- **Your source stays protected.** The build **minifies** your code — it still runs perfectly, but anyone who
|
||||
grabs it off a partner site gets compact, hard‑to‑read output, not your clean source.
|
||||
- **Update once, everywhere.** Serve the built maps from a **CDN**, and when you rebuild, every partner site
|
||||
that embeds a map picks up the latest version when their cache refreshes.
|
||||
|
||||
**One codebase → your own map website · embedded on partner sites · WordPress · mobile app · standalone.**
|
||||
|
||||
---
|
||||
|
||||
## The Playbook — start a prayer movement with this map
|
||||
|
||||
The learn‑by‑doing use case this bundler was built for. Five steps, trainable to any team:
|
||||
|
||||
1. **List your partner teams.** Build a spreadsheet of every team you know that is working to
|
||||
multiply disciples among the unreached — their websites, and the contact who can edit each site.
|
||||
2. **Keep the list in a secure, collaborative CRM — not a loose file.** That list of teams,
|
||||
contacts, and sites is an **attack surface** in the wrong hands; protect the people on it.
|
||||
[Disciple.Tools](https://disciple.tools) is an open‑source, multiplayer CRM built exactly for
|
||||
tracking teams, contacts, and mobilization campaigns — privately.
|
||||
3. **Commission prayer mobilizers.** Assign real people to work the list — call, network, and ask
|
||||
each team to put the prayer map on their site and present it to their spiritual family.
|
||||
4. **Embed everywhere.** Press the map's **Share button**: *Share Link* is the map's own page,
|
||||
*Add to Your Website* is the embed code — both carry the full URL of whatever host serves your
|
||||
maps (plus a `utm_source` tag on the generated pages, so every site's traffic is measurable).
|
||||
Verified places a pasted embed works: team websites and WordPress (Custom HTML block),
|
||||
Squarespace (Code block — iframes need a Business or Commerce plan), Wix (Add → Embed → HTML
|
||||
iframe), Google Sites (Insert → Embed → By URL), Notion (`/embed` + the Share Link), and internal
|
||||
boards with an iframe add-on (e.g. Trello's *Card Frames* Power-Up). Rule of thumb: anywhere that
|
||||
accepts an HTML snippet or a URL embed, over https.
|
||||
5. **Train the trainer.** Teach each team to use the Share button to embed the map — then teach
|
||||
them to train the *next* team the same way. The movement doesn't hang on one mobilization
|
||||
team: every team that carries the map can multiply it.
|
||||
|
||||
The result: one signup flow, a thousand doorways into it — and a map that visibly changes as
|
||||
people commit, so every team watches the task shrink on *their own* site.
|
||||
|
||||
## A word before you decide
|
||||
|
||||
This architecture is probably different from what you're used to. **Different doesn't mean worse** — it
|
||||
means it can make your life better and let you build the maps of your dreams.
|
||||
|
||||
So be honest with yourself. If you don't have big dreams about map‑making, this product might not be for
|
||||
you — and that's okay. But if you dream of maps that change the world and carry the gospel to places it has
|
||||
never been, you're going to need **more than a vibe‑coded map**. You'll need a *framework* that supports far
|
||||
more components than a typical one‑off map. And you'll need a *community*.
|
||||
|
||||
You can't form a community around spaghetti‑coded maps. You **can** form one around a shared framework with a
|
||||
reusable library — because then people prototype on the same foundation and build something stronger
|
||||
together, where maps never have to be rewritten from scratch. One problem that hinders the Great Commission, is each worker keeps their maps private source, and don't have a shared map framework to build maps on, with reusable map components in a modern framework like VUE with VITE embeddability support. **Doxa-maps** aims to meet this open source map gap for a global prayer movement which needs more maps, maps, maps!
|
||||
|
||||
## Requirements — the one small wall (and it's worth it)
|
||||
|
||||
Someone once told me: *don't reinvent the wheel — use Vue and Vite instead of hand‑rolling vanilla
|
||||
JavaScript.* They were right. So here's the honest deal before you start:
|
||||
|
||||
1. **Commit to learning Vue.** This bundler is built on **Vue + Vite** (React skills transfer —
|
||||
the components you'll touch are Vue).
|
||||
You don't have to master it — but pick one and lean in. (Afraid you can't learn a new architecture? You
|
||||
can. The concepts are one search away — that's what the glossary above is for.)
|
||||
2. **Learn the four basics** (your agent can run all of them):
|
||||
- `bun install` — get the dependencies
|
||||
- `bun run dev` — the dev server (live preview)
|
||||
- `bun run build` — the build step (ship it)
|
||||
- what `node_modules` are (the downloaded dependencies)
|
||||
3. **You do *not* need to write the code.** Give your agent access to the example maps in `plugin-designs/` plus
|
||||
the shared `library/`, describe your map in plain English, and it builds a new map that reuses the library
|
||||
instead of duplicating spaghetti. The library is for reusable components while the app profiles is for storing custom components that are not reusable, and are specific to that app-profile or map-app. Each map app has custom components that are not reusable and they are not appropriate in the shared library, but if you look at other plugin-designs you will see they store their custom components, as well as their main.vue file for the map in the plugin-designs folder.
|
||||
|
||||
That's the wall. It's small, and everything past it is the maps of your dreams.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so installs are reproducible).
|
||||
Install **[Bun](https://bun.sh)** once, then:
|
||||
|
||||
```bash
|
||||
bun install # download node_modules (one time)
|
||||
|
||||
bun run dev # the plugin gallery: http://localhost:5173 (or the next free port — read the terminal)
|
||||
# an auto-generated SPA of every map, grouped by bundle and composition tier,
|
||||
# each deep-linkable; `bun run smoke:gallery` proves every one of them boots
|
||||
|
||||
bun run build # the build step: every plugin-designs/<map>/ becomes
|
||||
# public/doxa-maps/<map>/<map>.js + a standalone index.html per map
|
||||
|
||||
bun run serve # preview the BUILT tree (the SPA shell + every embed page)
|
||||
bun run phone # build → sync → Android APK on a connected phone (Capacitor)
|
||||
bun run dev # live plugin gallery at http://localhost:5173
|
||||
bun run build # build every plugin-designs/<map>/ → public/doxa-maps/
|
||||
bun run serve # preview the built tree
|
||||
bun run phone # build → Capacitor → Android APK on a connected device
|
||||
```
|
||||
|
||||
`bun run build` writes everything into **`public/doxa-maps/`** — that's your output folder. Copy it wherever
|
||||
your site or CDN serves files (for example, a host repo's `public/` directory), and your maps are live.
|
||||
The full command matrix (per deploy method, with prereqs like the Android SDK / JDK for mobile) lives in
|
||||
**§3 → [docs/README-SECTION-3-cli-commands.md](./docs/README-SECTION-3-cli-commands.md)**.
|
||||
|
||||
### Embed a map
|
||||
|
||||
```html
|
||||
<!-- 1) the map engine (Mapbox shown here) -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
|
||||
<!-- 2) your built map + its custom tag -->
|
||||
<script src="/doxa-maps/my-map/my-map.js"></script>
|
||||
<doxa-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></doxa-map>
|
||||
```
|
||||
|
||||
Prefer not to run JavaScript on the host page? **iframe the generated page instead:**
|
||||
`/doxa-maps/<map>/<profile>/index.html`. Both snippets are pre‑generated for every map in
|
||||
`public/doxa-maps/manifest.json`.
|
||||
> **The data caveat:** maps ship **without data**. Being JAMstack, each fetches its people-group data from an
|
||||
> **API** at runtime, and its Mapbox token from a token endpoint (never baked into the bundle). That is what
|
||||
> keeps them embeddable everywhere. See [docs/how-to.md](./docs/how-to.md).
|
||||
|
||||
---
|
||||
|
||||
## The data caveat
|
||||
## 1. Plugin types — one screenshot each
|
||||
|
||||
Read this so nothing surprises you: **the maps ship without their data.** Because they're JAMstack (pure
|
||||
JavaScript), each map fetches its data — like people‑group information — from an **API** at runtime. That's
|
||||
what keeps them embeddable everywhere.
|
||||
Every image below is a real map from this repo's `bun run build` output (`public/doxa-maps/`), captured in a
|
||||
headless browser.
|
||||
|
||||
Your map data has to come from somewhere. Common sources:
|
||||
### CAP 1 — Single element
|
||||
|
||||
- **[Joshua Project](https://joshuaproject.net)**
|
||||
- **[PeopleGroups.org](https://peoplegroups.org)**
|
||||
- **A project API.** doxa-maps was originally built to serve **DX**, which runs its own people‑group API
|
||||
(`https://pray.doxa.life`) covering the unreached people groups these maps were made to pray over. The data
|
||||
source, the map token, and even the map engine (Mapbox ↔ MapLibre) are **runtime settings** — change them
|
||||
with **no rebuild**.
|
||||
*One folder → one bundle → one custom element.* The lightest tier: `plugin-designs/doxa-vision-card/`. No map
|
||||
engine, mounts instantly — a self-contained "mission vision card."
|
||||
|
||||

|
||||
|
||||
### CAP 2 — Preset element (prop-table)
|
||||
|
||||
*One `root.vue`, many maps.* `plugin-designs/doxa-simple-map/` is a single parameterized profile. A preset
|
||||
prop-table (`simple-map.instances.json`) renders it three ways — **prayer**, **engagement**, **adoption** —
|
||||
each a standalone embeddable page. Same code, three maps.
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|  |  |  |
|
||||
|
||||
*All three come from the **same** `doxa-simple-map` plugin-design folder — differentiated only by preset props.*
|
||||
|
||||
### CAP 3 — Monolithic research map
|
||||
|
||||
*A whole research map, all from one `root.vue`.* `plugin-designs/doxa-research-map/` — the monolithic tier.
|
||||
|
||||

|
||||
|
||||
### CAP 4 — Composite element
|
||||
|
||||
*A composite element that nests lower-tier elements and wires them via props/events.* `plugin-designs/my-upg-100-list/` —
|
||||
the UPG-100 list plugin. The composite (`upg-100-mfe`) plus its list pages:
|
||||
|
||||
| Composite element (`upg-100-mfe`) | List — page 1 | List — page 2 |
|
||||
|-----------------------------------|---------------|---------------|
|
||||
|  |  |  |
|
||||
|
||||
### CAP 5 — App shell / the doxa-maps app
|
||||
|
||||
*The top tier: every plugin in one deep-linkable single-page app* — `public/index.html`, generated by the
|
||||
build. A vertical-tab sidebar groups every plugin by capability (CAP 5 → 1); clicking a row mounts that
|
||||
plugin. This same SPA is the Capacitor mobile-app entry (see deploy 2).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
## 2. Deployment methods — one screenshot each
|
||||
|
||||
The exact commands + install prereqs for each are in **§3**. These shots show what each method looks like live.
|
||||
|
||||
### Deploy 1 — iframe embed
|
||||
|
||||
*The simplest.* One `<iframe>` line drops a map into an "embed section" on any partner site — no build step on
|
||||
their site. Below: the prayer map running in an iframe inside a church's missions page.
|
||||
|
||||

|
||||
|
||||
### Deploy 2 — mobile app
|
||||
|
||||
*Same code, native app.* `bun run phone` wraps the SPA into an Android app via Capacitor. Below: the doxa-maps
|
||||
SPA at a phone viewport — the exact web output that `cap:sync` copies into the APK.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/readme-images/deploy2-mobile-spa.png" alt="Deploy 2 — the doxa-maps SPA at phone viewport (the Capacitor mobile-app surface)" width="280">
|
||||
</p>
|
||||
|
||||
### Deploy 3 — research dashboard (direct-JS)
|
||||
|
||||
*Props + events + SRI.* `plugin-designs/research-dashboard/` embeds via a direct `<script src>` (with a
|
||||
Subresource-Integrity hash) so the map talks to the surrounding page — a coordinated-views dashboard where the
|
||||
map, the facet rail, and the people-groups table brush-and-link over one store.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3. CLI commands + installs (per deploy method)
|
||||
|
||||
The complete, copy-paste command matrix — every deploy method mapped to its install prereqs and exact
|
||||
commands, grounded in this branch's `package.json` — lives in its own doc:
|
||||
|
||||
**→ [docs/README-SECTION-3-cli-commands.md](./docs/README-SECTION-3-cli-commands.md)**
|
||||
|
||||
Quick reference:
|
||||
|
||||
| # | Deploy method | Install first | Command(s) |
|
||||
|---|---------------|---------------|------------|
|
||||
| 1 | **iframe embed** | Bun | `bun install` → `bun run build` → `bun run serve` (or drop `public/doxa-maps/` on any host) |
|
||||
| 2 | **Mobile (Android)** | Bun · Android SDK + `adb` · JDK 17 · Capacitor (devDep) | `bun run phone` |
|
||||
| 3 | **Direct-JS / dashboard** | Bun | `bun run build`, then paste the `<script src> + integrity` snippet from `manifest.json` |
|
||||
| 4 | **WordPress** | Bun | `bun run build`, then paste the iframe/script snippet into a Custom HTML block *(deferred POC)* |
|
||||
| 5 | **Desktop (Tauri)** | Rust + Tauri CLI *(not yet scaffolded)* | *deferred POC* |
|
||||
|
||||
Every build writes `public/doxa-maps/manifest.json` — a machine-readable index carrying the ready-to-paste
|
||||
**iframe** and **`<script src>` + SRI** embed snippet for every map.
|
||||
|
||||
---
|
||||
|
||||
## More docs
|
||||
|
||||
| Read this | When |
|
||||
|---|---|
|
||||
| **[docs/README-SECTION-3-cli-commands.md](./docs/README-SECTION-3-cli-commands.md)** | the full per-method CLI + install matrix |
|
||||
| **[docs/why.md](./docs/why.md)** | 2 min — the problem, the idea, one diagram |
|
||||
| **[docs/tutorial.md](./docs/tutorial.md)** | build your first map, step by step (great to hand to your agent) |
|
||||
| **[docs/tutorial.md](./docs/tutorial.md)** | build your first map, step by step |
|
||||
| **[docs/how-to.md](./docs/how-to.md)** | task recipes (embed, data, mobile) |
|
||||
| **[layouts/README.md](./layouts/README.md)** | the SPA composition machinery (many maps → one app) |
|
||||
| **[docs/README-longform.md](./docs/README-longform.md)** | the original story-first README (the "why maps" manifesto) |
|
||||
| **[docs/architecture/](./docs/architecture/)** | the deep end — embeddability, app-wrappers, file map |
|
||||
|
||||
---
|
||||
|
||||
## A note from the developer
|
||||
|
||||
> **Bottom line: maps should be open source, they should run everywhere, and no movement of God has ever
|
||||
> moved without maps.**
|
||||
|
||||
You found some really cool maps on the internet, didn't you? On discipleship sites, maybe — maps that
|
||||
inspired you, that made you want to build your own. But the code wasn't available. Or if it was, it wasn't
|
||||
written to run on WordPress, or on your own site, or across your partner teams' websites. It wasn't
|
||||
simple. It wasn't modular. It didn't work the way you needed it to.
|
||||
|
||||
That was me too. That's why I built this framework. I love maps — I'm going to build maps until the day I
|
||||
die — because I want to see the Kingdom of God advance to the places it has never been, and I long to see a
|
||||
global movement of map‑makers multiplying maps everywhere.
|
||||
|
||||
I want the Great Commission fulfilled in my lifetime, and there is no global movement without map‑making.
|
||||
Look at Scripture: *Jerusalem, Judea, Samaria, and the ends of the earth* — the book of Acts is a map, all
|
||||
the way to Rome. Where do you find a movement of God without maps at its white‑hot center? Nowhere. So why
|
||||
would we lock our maps away and make them closed source? Why should the power to make a map sit in the hands
|
||||
of a few who never release their code?
|
||||
|
||||
It shouldn't. **This was built to be open source, to serve movements of God** — so the maps can multiply to
|
||||
every device: WordPress, your own site, an embeddable script on Squarespace, mobile, anywhere an embed can
|
||||
run. If we want to put map‑making into the hands of every disciple, we need a framework that lets the maps
|
||||
*go*, lets them be *reusable*, and lets a community build them *together*.
|
||||
|
||||
If you have a vision to reach the globe but you've been stuck vibe‑coding an entire platform alone — with no
|
||||
open‑source foundation that runs on your site and your partners' sites — that gap isn't your fault, and you
|
||||
don't have to stay there. You can escape it by adopting a framework that has already been where you are.
|
||||
|
||||
Come build the maps of your dreams. Let's reach every people, in every place.
|
||||
> **Bottom line: maps should be open source, run everywhere, and multiply.** This bundler makes each map a
|
||||
> self-contained file that embeds on your site, your partners' sites, WordPress, and mobile — from one build.
|
||||
|
|
|
|||
132
docs/README-SECTION-3-cli-commands.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
## 3. CLI commands + required installs (per deployment method)
|
||||
|
||||
Everything runs through `bun` and the scripts in `package.json`. The build loop
|
||||
iterates every folder under **`plugin-designs/`** (each folder = one bundle), so once a
|
||||
map exists there, `bun run build` picks it up automatically — no per-map wiring.
|
||||
|
||||
> **Install once (all methods):** [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`) — Node ≥ 18 also works (`npm run <script>`; the scripts are identical). Then:
|
||||
>
|
||||
> ```bash
|
||||
> bun install
|
||||
> ```
|
||||
|
||||
The scripts these commands are grounded in (from this branch's `package.json`):
|
||||
|
||||
```jsonc
|
||||
"dev": "bun x vite",
|
||||
"build": "bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:drop && bun run check:parse && bun run verify && bun run gallery",
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public",
|
||||
"cap:sync": "bunx cap sync android",
|
||||
"cap:verify": "test -f capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL ...\" && exit 1)",
|
||||
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd capacitor/android && ./gradlew assembleDebug",
|
||||
"apk:install": "adb install -r capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"phone": "bun run apk:debug && bun run apk:install"
|
||||
```
|
||||
|
||||
`bun run build` writes the whole deployable tree to `public/doxa-maps/`, including
|
||||
`manifest.json` — the machine-readable index that carries the ready-to-paste
|
||||
**iframe** and **`<script src>` + SRI** embed snippets for every map.
|
||||
|
||||
### Summary table
|
||||
|
||||
| # | Deploy method | Install first (prereqs) | Command(s) |
|
||||
|---|---------------|-------------------------|------------|
|
||||
| 1 | **iframe embed** | Bun | `bun install` → `bun run build` → `bun run serve` (or drop `public/doxa-maps/` on any host) |
|
||||
| 2 | **Mobile app (Android)** | Bun · Android SDK + `adb` on PATH · JDK 17 (Gradle) · Capacitor (already a devDep) | `bun run phone` (= `apk:debug` build + `apk:install` to a connected device/emulator) |
|
||||
| 3 | **Direct-JS / dashboard** | Bun | `bun run build`, then paste the `<script src> + integrity` (SRI) snippet from `manifest.json` |
|
||||
| 4 | **WordPress** | Bun (build only) | `bun run build`, then paste the same iframe or script snippet into a WordPress **Custom HTML** block. *(No dedicated WP build script — deferred POC.)* |
|
||||
| 5 | **Desktop (Tauri)** | Rust toolchain + Tauri CLI *(not yet scaffolded)* | **Deferred POC** — no command exists on this branch yet. |
|
||||
|
||||
---
|
||||
|
||||
### 1. iframe embed *(required)*
|
||||
|
||||
**Install:** Bun.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build # builds every plugin-designs/* bundle -> public/doxa-maps/
|
||||
bun run serve # optional: serves public/ at http://localhost:4173
|
||||
```
|
||||
|
||||
The build writes `public/doxa-maps/manifest.json`. Each profile carries an
|
||||
`embed.iframe` snippet, e.g.:
|
||||
|
||||
```html
|
||||
<iframe src="./all-people-groups/all-people-groups/index.html"
|
||||
width="100%" height="600"
|
||||
style="border:none;border-radius:12px;" allowfullscreen></iframe>
|
||||
```
|
||||
|
||||
URLs are root-relative, so the whole `doxa-maps/` folder is position-independent —
|
||||
drop it on Cloudflare/Vercel/any static host and every iframe resolves. (For an
|
||||
external absolute base, set `embedBaseUrl` in the build config before building.)
|
||||
|
||||
### 2. Mobile app — Android / Capacitor *(required)*
|
||||
|
||||
**Install:** Bun · **Android SDK** (`sdkmanager`, `adb` on your PATH) · **JDK 17**
|
||||
(the Gradle wrapper needs it) · Capacitor (`@capacitor/*` are already devDependencies,
|
||||
so `bun install` covers them). A connected device or running emulator for install.
|
||||
|
||||
```bash
|
||||
bun run phone
|
||||
```
|
||||
|
||||
`phone` chains the whole path: `apk:debug` (which runs `bun run build`, then
|
||||
`cap:sync` to copy the web output into the Android project, then `cap:verify`, then
|
||||
`./gradlew assembleDebug`) and finally `apk:install` (`adb install -r ...`) onto the
|
||||
device. Run the halves separately if you want to inspect between steps:
|
||||
|
||||
```bash
|
||||
bun run apk:debug # build the APK only
|
||||
bun run apk:install # push the built APK to a connected device
|
||||
```
|
||||
|
||||
Only Android is scaffolded on this branch (`capacitor/android`, `capacitor.config.json`);
|
||||
iOS is not set up.
|
||||
|
||||
### 3. Direct-JS / dashboard *(required)*
|
||||
|
||||
**Install:** Bun.
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Then paste the profile's `embed.script` snippet from `manifest.json` — a `<script src>`
|
||||
with a Subresource-Integrity (SRI) hash plus the custom-element tag:
|
||||
|
||||
```html
|
||||
<script src="./all-people-groups/all-people-groups.js"
|
||||
integrity="sha384-j5Ypx+EI9DGlTClzgLs1R8wAnmTnkQ7HgAhHrxyGoVoHDMTlFVY7slU6JNicn77T"
|
||||
crossorigin="anonymous"></script>
|
||||
<all-people-groups
|
||||
profile-config='{"profile":"all-people-groups","tk":"YOUR_MAPBOX_TOKEN"}'>
|
||||
</all-people-groups>
|
||||
```
|
||||
|
||||
The element accepts props / emits events, so it can wire into a surrounding page as a
|
||||
unified research dashboard (deploy-3 proof). The SRI hash regenerates on every build.
|
||||
|
||||
### 4. WordPress *(deferred POC)*
|
||||
|
||||
No dedicated WordPress build step exists on this branch. WordPress is served by the
|
||||
**same** artifacts as methods 1 and 3:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Then paste either the iframe snippet **or** the `<script src> + SRI` snippet from
|
||||
`manifest.json` into a WordPress **Custom HTML** block. A first-class WP plugin/build
|
||||
step is a deferred POC (see backlog `_meta`: "deploy methods 4/5 are DEFERRED").
|
||||
|
||||
### 5. Desktop — Tauri *(deferred POC)*
|
||||
|
||||
**Intended prereqs:** the Rust toolchain (`rustup`) + the Tauri CLI, wrapping the same
|
||||
`bun run build` web output as a native desktop shell.
|
||||
|
||||
**Status: deferred POC.** No Tauri scaffolding or build script exists on this branch
|
||||
yet, so there is no copy-paste command to run today. Documented here for completeness
|
||||
of the 5-method model.
|
||||
305
docs/README-longform.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# doxa-maps — prayer maps that embed everywhere
|
||||
|
||||
> **Bottom line up front.** A global prayer movement stalls when there is only **one** website where people
|
||||
> can see the task and sign up — today that plateau sits at roughly 600–1,000 people groups covered. The fix is
|
||||
> not a better single website. It is a map that lives on **thousands** of partner-team websites at once, shows
|
||||
> the prayer task clearly, changes as people sign up, and carries a **Share** button so every team that receives
|
||||
> it can pass it on. This bundler builds that map — and every other map you need — as one embeddable file each.
|
||||
|
||||
## Why embed, not link
|
||||
|
||||
One sign-up site is a bottleneck. A map embedded on a partner team's *own* site multiplies three surfaces at
|
||||
once: the **prayer surface** (more people see the task), the **information surface** and the **understanding
|
||||
surface**. And a team that has the map on its own site treats it as its own — ownership becomes stewardship.
|
||||
Sign-up can still happen in one place; the *map* does not have to.
|
||||
|
||||
## What this trains you to be (Training for Trainers)
|
||||
|
||||
We borrow the language of **Training for Trainers (T4T)** — Ying Kai's method, told in *T4T: A Discipleship
|
||||
Re-Revolution* (Steve Smith with Ying Kai, 2011; trainer resources at [t4tonline.org](https://t4tonline.org)):
|
||||
the goal is never one trained person, but trainers who train trainers. This README trains you to be three things:
|
||||
|
||||
1. a **map maker** — you (with your AI agent) build maps on the shared library in this repo;
|
||||
2. a **map multiplier** — every map you build embeds anywhere and carries a Share button;
|
||||
3. a **prayer mobilizer** — you run the campaign that multiplies the surface: the map onto the website of
|
||||
every team you know that is trying to multiply disciples among the unreached, and each of them trained to
|
||||
pass it on with the same button — **[the Playbook](#the-playbook--start-a-prayer-movement-with-this-map)**.
|
||||
|
||||
Everything below is for the people who build the maps. If you only want to *run the campaign*, jump to the
|
||||
Playbook; the full walkthrough is [docs/tutorial.md](./docs/tutorial.md).
|
||||
|
||||
---
|
||||
|
||||
## What the bundler is — a JAMstack map bundler for vibe coders
|
||||
|
||||
> **Built by a vibe coder, for vibe coders** — and for map multipliers and prayer mobilizers who
|
||||
> want every people, in every place, prayed for by name.
|
||||
|
||||
**One build turns every map into a self‑contained web component** that embeds anywhere an embed
|
||||
script or URL works, and every map carries the **Share button** so any team can pass its map to the
|
||||
next team. **The seemingly impossible part:** the same build also composes all your maps, tables, and
|
||||
dashboards into a single‑page application that runs as a website, installs as a mobile app, and
|
||||
embeds on partner sites piece by piece. *Same code, every screen.*
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/spa-desktop.png" alt="The composed SPA on desktop — many maps, tables and dashboards in one app" width="680">
|
||||
<img src="docs/assets/spa-mobile.png" alt="The same SPA, same code, as a phone app" width="170">
|
||||
</p>
|
||||
<p align="center"><em>One build, same code: the SPA in a desktop browser and on a phone — many maps, one place.</em></p>
|
||||
|
||||
- **One repo, one library** — every map shares components. Not fifty folders of duplicated spaghetti.
|
||||
- **`bun run dev`** → a live plugin gallery of every map, each with a copy‑paste embed snippet.
|
||||
- **`bun run build`** → every map ships as its own self‑contained embeddable file (script tag or iframe — WordPress included), and the same build feeds the SPA and the mobile app.
|
||||
|
||||
**You don't write the code.** Point your AI agent at the example maps, describe what you want in plain English, and it builds a new one on the shared library.
|
||||
|
||||
> ⚠️ **Maps ship without data** — being JAMstack (pure JS), they fetch it (like people groups) from an **API** at runtime ([details](#the-data-caveat)).
|
||||
|
||||
---
|
||||
|
||||
## Have you ever dreamed of making maps?
|
||||
|
||||
Here's the story. Maybe it's yours too.
|
||||
|
||||
You got into building software right as AI made it possible for anyone to write code. And you had a
|
||||
vision: **make maps** — every map you could imagine — to advance the Kingdom of God, so that every people
|
||||
in every place could hear the gospel of Jesus.
|
||||
|
||||
So you started. And it worked… for one map. AI is *great* at writing a single map — a couple thousand
|
||||
lines of code you can embed on a website.
|
||||
|
||||
But then you wanted a second map. And a fifth. And a fiftieth. And here's the wall you hit:
|
||||
|
||||
- Every new map meant **copying the whole thing** into a new folder — nothing was reusable.
|
||||
- Your maps grew past 2,000… 5,000… lines of tangled "spaghetti code."
|
||||
- You ended up with **50 folders, 50 map projects**, scattered across your machine — half of them lost.
|
||||
- Each one was fragile, glitchy, and difficult to maintain, and harder to form a community around.
|
||||
- You tried to build the bigger platform — many maps in one app — and the agent got stuck past map one. Somewhere in there, the mobile‑app dream quietly went out the window.
|
||||
|
||||
**If that's you: you built maps for Jesus. I salute you.** Those maps have their place. But you were
|
||||
missing one thing — a *framework* that lets all your maps live in one place and share reusable pieces.
|
||||
|
||||
That's what this is. Built for communities of map makers who want to make open source maps that can multiply everywhere. And the dream you threw out the window? The SPA here runs on your phone **by design** — same code.
|
||||
|
||||
---
|
||||
|
||||
## Key terms (don't be afraid of these — you can learn each in one search)
|
||||
|
||||
New to this? Read this table first. You do **not** need to memorize it — just know the words exist and where to learn them.
|
||||
|
||||
| Term | In plain words | Learn more |
|
||||
|---|---|---|
|
||||
| **Vibe coder** | Someone who builds with AI writing most of the code. That's who this is for. | — |
|
||||
| **JAMstack** | A way of building where the front end is just **J**avaScript, **A**PIs, and **M**arkup (HTML/CSS). It runs almost anywhere — including WordPress. | [jamstack.org](https://jamstack.org) |
|
||||
| **Bundler** | A tool that takes all your code and packs it into a few ready‑to‑ship files. This repo *is* a bundler for maps. | [vite.dev/guide](https://vite.dev/guide/) |
|
||||
| **Build step** | The one command (`bun run build`) that turns your code into those shippable files. Your agent can run it for you. | — |
|
||||
| **bun** | A fast, all‑in‑one replacement for `npm` (installs packages, runs scripts). We use it here. | [bun.sh](https://bun.sh) |
|
||||
| **node modules** | The reusable code packages your project depends on. If you use VUE, and VITE you need Node Modules. It is like installing libraries and dependencies that allow VUE to work. `bun install` downloads them into your doxa-maps folder on your laptop `node_modules/`. | [bun.sh/docs](https://bun.sh/docs/cli/install) |
|
||||
| **Dev server** | A local preview website (run this command 'bun run dev' after 'bun install') that updates live as you edit. You run it, it let's you see all the maps in one place so you can give your agents feedback, and see changes almost real time when the agent makes edits. It runs at `http://localhost:5173`. | — |
|
||||
| **Vue / React** | Popular, well‑supported frameworks for building front ends. This bundler is built on **Vue** + **Vite** — commit to learning Vue (if you know React, the concepts transfer). | [vuejs.org](https://vuejs.org) |
|
||||
| **Web component** | A custom HTML tag (like `<doxa-map>`) that works on any page, no framework required. Each of your maps becomes one. | [MDN: Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) |
|
||||
| **iframe** | A safe window that embeds one site inside another. Simple, isolated. Each map comes with iframe support by default | [MDN: iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) |
|
||||
| **Shadow DOM** | Lets your map's JavaScript run *directly* inside the host page (not walled off in an iframe) so it can integrate deeply — while keeping its styles from clashing. | [MDN: Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) |
|
||||
| **SPA (single‑page application)** | One page that composes several maps together in a layout — a whole multi-map site from one build. | [MDN: SPA](https://developer.mozilla.org/en-US/docs/Glossary/SPA) |
|
||||
| **CDN** | A URL that serves a file (JavaScript/CSS) to be fetched and run in a browser. When you use Mapbox, you're loading its CDN. Your built maps are served the same way. JAMstack allows these map frontends to run through a CDN, yes they are that modular! Or, you can run them directly in your site, but CDN allows them to embed via iframe onto partner websites easily with a single URL | [MDN: CDN](https://developer.mozilla.org/en-US/docs/Glossary/CDN) |
|
||||
| **Minify** | The build shrinks your code into compact, hard‑to‑read (but still working) form — smaller and harder to copy. | [MDN: Minification](https://developer.mozilla.org/en-US/docs/Glossary/Minification) |
|
||||
| **Capacitor** | Wraps your maps app into a real iOS/Android apps, or make a SPA ('single-page application' see above) by joining multiple maps together into a layout, and bundling that SPA into IOS/Android easily — **same code, no Swift, no Flutter, no React Native.** (The Android project ships in this repo; iOS scaffolds the same way on a Mac.) | [capacitorjs.com](https://capacitorjs.com) |
|
||||
| **Lazy loading** | Only loads each map when it's actually needed, so a page with many maps stays fast. | [web.dev: lazy loading](https://web.dev/articles/lazy-loading) |
|
||||
|
||||
---
|
||||
|
||||
## What doxa-maps gives you
|
||||
|
||||
- **One repository for all your maps.** Each map is an *application profile* in `plugin-designs/<your-map>/`.
|
||||
- **A shared library of reusable, parameterized components** (`library/` — the map, the legend, the
|
||||
controls, the search bar) so your maps stop duplicating code.
|
||||
- **Room for each map's own custom code** — because real maps always have something unique.
|
||||
- **Rapid prototyping.** Add a `.vue` app-profile file, rebuild, and it's live. Build *hundreds* of maps from one library.
|
||||
- **Your agent's happy place.** Everything's in one folder, so your AI agent has all the context it needs to
|
||||
build the next map from the examples already here. Give your agent the proper context and it will have what it needs to make new maps. The only limit is our fear of new architecture, we can learn JAMstack, and see the use cases, and grow in a new framework of software design that is appropriate for open source maps that need to embed everywhere.
|
||||
- **The Great Commission's Maps** Everyone needs to work together to fulfill the great commission, and it will not be possible without maps. Allowing map communities to multiply open source maps that can embed onto all partner sites means doxa-maps is built to solve global mobilization problems, not just a map for centralized website.
|
||||
|
||||
---
|
||||
|
||||
## The parts that make you do a backflip
|
||||
|
||||
- **One build → many embeddable maps.** `bun run build` turns every map into a self‑contained web component
|
||||
*and* generates a standalone HTML page for each one.
|
||||
- **Embed anywhere.** Drop a map on your own site, a partner team's site, or WordPress — via an **iframe**
|
||||
(safe and isolated) *or* via **shadow DOM** (the map's JavaScript runs directly in the page, for deep
|
||||
integration and advanced features).
|
||||
- **Compose maps into a single‑page app.** Put several maps into one layout — a whole multi‑map site — and it
|
||||
**lazy‑loads** each map so it stays fast even when there are many.
|
||||
- **Turn it into a mobile app.** The same code runs as an **iOS/Android app** via **Capacitor** — no new
|
||||
language to learn. Design it mobile‑friendly right in your browser (inspect element → switch to a phone
|
||||
aspect ratio), then wrap it.
|
||||
- **Your source stays protected.** The build **minifies** your code — it still runs perfectly, but anyone who
|
||||
grabs it off a partner site gets compact, hard‑to‑read output, not your clean source.
|
||||
- **Update once, everywhere.** Serve the built maps from a **CDN**, and when you rebuild, every partner site
|
||||
that embeds a map picks up the latest version when their cache refreshes.
|
||||
|
||||
**One codebase → your own map website · embedded on partner sites · WordPress · mobile app · standalone.**
|
||||
|
||||
---
|
||||
|
||||
## The Playbook — start a prayer movement with this map
|
||||
|
||||
The learn‑by‑doing use case this bundler was built for. Five steps, trainable to any team:
|
||||
|
||||
1. **List your partner teams.** Build a spreadsheet of every team you know that is working to
|
||||
multiply disciples among the unreached — their websites, and the contact who can edit each site.
|
||||
2. **Keep the list in a secure, collaborative CRM — not a loose file.** That list of teams,
|
||||
contacts, and sites is an **attack surface** in the wrong hands; protect the people on it.
|
||||
[Disciple.Tools](https://disciple.tools) is an open‑source, multiplayer CRM built exactly for
|
||||
tracking teams, contacts, and mobilization campaigns — privately.
|
||||
3. **Commission prayer mobilizers.** Assign real people to work the list — call, network, and ask
|
||||
each team to put the prayer map on their site and present it to their spiritual family.
|
||||
4. **Embed everywhere.** Press the map's **Share button**: *Share Link* is the map's own page,
|
||||
*Add to Your Website* is the embed code — both carry the full URL of whatever host serves your
|
||||
maps (plus a `utm_source` tag on the generated pages, so every site's traffic is measurable).
|
||||
Verified places a pasted embed works: team websites and WordPress (Custom HTML block),
|
||||
Squarespace (Code block — iframes need a Business or Commerce plan), Wix (Add → Embed → HTML
|
||||
iframe), Google Sites (Insert → Embed → By URL), Notion (`/embed` + the Share Link), and internal
|
||||
boards with an iframe add-on (e.g. Trello's *Card Frames* Power-Up). Rule of thumb: anywhere that
|
||||
accepts an HTML snippet or a URL embed, over https.
|
||||
5. **Train the trainer.** Teach each team to use the Share button to embed the map — then teach
|
||||
them to train the *next* team the same way. The movement doesn't hang on one mobilization
|
||||
team: every team that carries the map can multiply it.
|
||||
|
||||
The result: one signup flow, a thousand doorways into it — and a map that visibly changes as
|
||||
people commit, so every team watches the task shrink on *their own* site.
|
||||
|
||||
## A word before you decide
|
||||
|
||||
This architecture is probably different from what you're used to. **Different doesn't mean worse** — it
|
||||
means it can make your life better and let you build the maps of your dreams.
|
||||
|
||||
So be honest with yourself. If you don't have big dreams about map‑making, this product might not be for
|
||||
you — and that's okay. But if you dream of maps that change the world and carry the gospel to places it has
|
||||
never been, you're going to need **more than a vibe‑coded map**. You'll need a *framework* that supports far
|
||||
more components than a typical one‑off map. And you'll need a *community*.
|
||||
|
||||
You can't form a community around spaghetti‑coded maps. You **can** form one around a shared framework with a
|
||||
reusable library — because then people prototype on the same foundation and build something stronger
|
||||
together, where maps never have to be rewritten from scratch. One problem that hinders the Great Commission, is each worker keeps their maps private source, and don't have a shared map framework to build maps on, with reusable map components in a modern framework like VUE with VITE embeddability support. **Doxa-maps** aims to meet this open source map gap for a global prayer movement which needs more maps, maps, maps!
|
||||
|
||||
## Requirements — the one small wall (and it's worth it)
|
||||
|
||||
Someone once told me: *don't reinvent the wheel — use Vue and Vite instead of hand‑rolling vanilla
|
||||
JavaScript.* They were right. So here's the honest deal before you start:
|
||||
|
||||
1. **Commit to learning Vue.** This bundler is built on **Vue + Vite** (React skills transfer —
|
||||
the components you'll touch are Vue).
|
||||
You don't have to master it — but pick one and lean in. (Afraid you can't learn a new architecture? You
|
||||
can. The concepts are one search away — that's what the glossary above is for.)
|
||||
2. **Learn the four basics** (your agent can run all of them):
|
||||
- `bun install` — get the dependencies
|
||||
- `bun run dev` — the dev server (live preview)
|
||||
- `bun run build` — the build step (ship it)
|
||||
- what `node_modules` are (the downloaded dependencies)
|
||||
3. **You do *not* need to write the code.** Give your agent access to the example maps in `plugin-designs/` plus
|
||||
the shared `library/`, describe your map in plain English, and it builds a new map that reuses the library
|
||||
instead of duplicating spaghetti. The library is for reusable components while the app profiles is for storing custom components that are not reusable, and are specific to that app-profile or map-app. Each map app has custom components that are not reusable and they are not appropriate in the shared library, but if you look at other plugin-designs you will see they store their custom components, as well as their main.vue file for the map in the plugin-designs folder.
|
||||
|
||||
That's the wall. It's small, and everything past it is the maps of your dreams.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so installs are reproducible).
|
||||
|
||||
```bash
|
||||
bun install # download node_modules (one time)
|
||||
|
||||
bun run dev # the plugin gallery: http://localhost:5173 (or the next free port — read the terminal)
|
||||
# an auto-generated SPA of every map, grouped by bundle and composition tier,
|
||||
# each deep-linkable; `bun run smoke:gallery` proves every one of them boots
|
||||
|
||||
bun run build # the build step: every plugin-designs/<map>/ becomes
|
||||
# public/doxa-maps/<map>/<map>.js + a standalone index.html per map
|
||||
|
||||
bun run serve # preview the BUILT tree (the SPA shell + every embed page)
|
||||
bun run phone # build → sync → Android APK on a connected phone (Capacitor)
|
||||
```
|
||||
|
||||
`bun run build` writes everything into **`public/doxa-maps/`** — that's your output folder. Copy it wherever
|
||||
your site or CDN serves files (for example, a host repo's `public/` directory), and your maps are live.
|
||||
|
||||
### Embed a map
|
||||
|
||||
```html
|
||||
<!-- 1) the map engine (Mapbox shown here) -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
|
||||
<!-- 2) your built map + its custom tag -->
|
||||
<script src="/doxa-maps/my-map/my-map.js"></script>
|
||||
<doxa-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></doxa-map>
|
||||
```
|
||||
|
||||
Prefer not to run JavaScript on the host page? **iframe the generated page instead:**
|
||||
`/doxa-maps/<map>/<profile>/index.html`. Both snippets are pre‑generated for every map in
|
||||
`public/doxa-maps/manifest.json`.
|
||||
|
||||
---
|
||||
|
||||
## The data caveat
|
||||
|
||||
Read this so nothing surprises you: **the maps ship without their data.** Because they're JAMstack (pure
|
||||
JavaScript), each map fetches its data — like people‑group information — from an **API** at runtime. That's
|
||||
what keeps them embeddable everywhere.
|
||||
|
||||
Your map data has to come from somewhere. Common sources:
|
||||
|
||||
- **[Joshua Project](https://joshuaproject.net)**
|
||||
- **[PeopleGroups.org](https://peoplegroups.org)**
|
||||
- **A project API.** doxa-maps was originally built to serve **DX**, which runs its own people‑group API
|
||||
(`https://pray.doxa.life`) covering the unreached people groups these maps were made to pray over. The data
|
||||
source, the map token, and even the map engine (Mapbox ↔ MapLibre) are **runtime settings** — change them
|
||||
with **no rebuild**.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
| Read this | When |
|
||||
|---|---|
|
||||
| **[docs/why.md](./docs/why.md)** | 2 min — the problem, the idea, one diagram |
|
||||
| **[docs/tutorial.md](./docs/tutorial.md)** | build your first map, step by step (great to hand to your agent) |
|
||||
| **[docs/how-to.md](./docs/how-to.md)** | task recipes (embed, data, mobile) |
|
||||
| **[layouts/README.md](./layouts/README.md)** | the SPA composition machinery (many maps → one app) |
|
||||
| **[docs/architecture/](./docs/architecture/)** | the deep end — embeddability, app-wrappers, file map |
|
||||
|
||||
---
|
||||
|
||||
## A note from the developer
|
||||
|
||||
> **Bottom line: maps should be open source, they should run everywhere, and no movement of God has ever
|
||||
> moved without maps.**
|
||||
|
||||
You found some really cool maps on the internet, didn't you? On discipleship sites, maybe — maps that
|
||||
inspired you, that made you want to build your own. But the code wasn't available. Or if it was, it wasn't
|
||||
written to run on WordPress, or on your own site, or across your partner teams' websites. It wasn't
|
||||
simple. It wasn't modular. It didn't work the way you needed it to.
|
||||
|
||||
That was me too. That's why I built this framework. I love maps — I'm going to build maps until the day I
|
||||
die — because I want to see the Kingdom of God advance to the places it has never been, and I long to see a
|
||||
global movement of map‑makers multiplying maps everywhere.
|
||||
|
||||
I want the Great Commission fulfilled in my lifetime, and there is no global movement without map‑making.
|
||||
Look at Scripture: *Jerusalem, Judea, Samaria, and the ends of the earth* — the book of Acts is a map, all
|
||||
the way to Rome. Where do you find a movement of God without maps at its white‑hot center? Nowhere. So why
|
||||
would we lock our maps away and make them closed source? Why should the power to make a map sit in the hands
|
||||
of a few who never release their code?
|
||||
|
||||
It shouldn't. **This was built to be open source, to serve movements of God** — so the maps can multiply to
|
||||
every device: WordPress, your own site, an embeddable script on Squarespace, mobile, anywhere an embed can
|
||||
run. If we want to put map‑making into the hands of every disciple, we need a framework that lets the maps
|
||||
*go*, lets them be *reusable*, and lets a community build them *together*.
|
||||
|
||||
If you have a vision to reach the globe but you've been stuck vibe‑coding an entire platform alone — with no
|
||||
open‑source foundation that runs on your site and your partners' sites — that gap isn't your fault, and you
|
||||
don't have to stay there. You can escape it by adopting a framework that has already been where you are.
|
||||
|
||||
Come build the maps of your dreams. Let's reach every people, in every place.
|
||||
BIN
docs/readme-images/cap1-single-element.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
docs/readme-images/cap2-adoption.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/readme-images/cap2-engagement.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/readme-images/cap2-prayer.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/readme-images/cap3-monolithic-research.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/readme-images/cap4-upg100-list-page1.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
docs/readme-images/cap4-upg100-list-page2.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
docs/readme-images/cap4-upg100-mfe.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
BIN
docs/readme-images/cap5-doxa-maps-spa.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/readme-images/deploy1-iframe.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/readme-images/deploy2-mobile-spa.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/readme-images/deploy3-research-dashboard.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
26
internal/acceptance/big-picture-acceptance.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env node
|
||||
// Big Picture (Cat-4) composite acceptance test — the Gate-1 chain end-to-end.
|
||||
// Exercises the REAL data flow: header emits research-filter {query,wagfRegion,wagfBlock,
|
||||
// rop1,religion} -> parent props -> grid's applyDropdownFilters -> groups-filtered {total}.
|
||||
// Runs against the live people-groups API (the same shape the grid fetches). Exit 1 on any fail.
|
||||
const API='https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang='
|
||||
function gridFilter(recs,{query='',wagfRegion='',wagfBlock='',rop1='',religion=''}={}){
|
||||
let out=recs
|
||||
for(const [f,sel] of Object.entries({wagf_region:wagfRegion,wagf_block:wagfBlock,rop1,religion}))
|
||||
if(sel) out=out.filter(u=>(u[f]||{}).value===sel)
|
||||
if(query){const q=query.toLowerCase()
|
||||
out=out.filter(u=>String(u.name||'').toLowerCase().includes(q)||
|
||||
['country_code','rop1','religion','wagf_region','wagf_block'].some(k=>String((u[k]||{}).label||'').toLowerCase().includes(q)))}
|
||||
return out
|
||||
}
|
||||
const r=await fetch(API); const d=await r.json(); const recs=d.posts||d.data||d; const N=recs.length
|
||||
let pass=0,fail=0
|
||||
const t=(name,ok,cnt)=>{ok?pass++:fail++;console.log(` ${ok?'✓':'✗ FAIL'} ${name} -> ${cnt}`)}
|
||||
t('no filter -> all',gridFilter(recs).length===N,gridFilter(recs).length)
|
||||
t('Region=Africa',gridFilter(recs,{wagfRegion:'africa'}).every(u=>(u.wagf_region||{}).value==='africa'),gridFilter(recs,{wagfRegion:'africa'}).length)
|
||||
t('Religion=Islam-Sunni',gridFilter(recs,{religion:'MSN'}).length>0,gridFilter(recs,{religion:'MSN'}).length)
|
||||
t('Asia+Hinduism (faceted)',gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length>0,gridFilter(recs,{wagfRegion:'asia',religion:'H'}).length)
|
||||
t("search 'somali'",gridFilter(recs,{query:'somali'}).some(u=>u.name.toLowerCase().includes('somali')),gridFilter(recs,{query:'somali'}).length)
|
||||
t("search 'a' within Africa",gridFilter(recs,{wagfRegion:'africa',query:'a'}).length>0,gridFilter(recs,{wagfRegion:'africa',query:'a'}).length)
|
||||
console.log(`\n Big Picture acceptance: ${pass} passed, ${fail} failed (${N} live records)`)
|
||||
process.exit(fail?1:0)
|
||||
47
internal/build-lock.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
# build-lock.sh — a first-wins BUILD MUTEX for `bun run build`.
|
||||
#
|
||||
# WHY: multiple agents (and RESUMED "idle" agents — a SendMessage revives one) can each run `bun run build`
|
||||
# into the shared public/ output. Concurrent builds clobber each other (a neighbor's `bun run clean` wipes
|
||||
# your in-progress output → false "bundle MISSING"). Nothing but coordinator-discipline prevented this — so
|
||||
# this makes the collision STRUCTURALLY impossible: the FIRST build acquires the lock; a SECOND build that
|
||||
# tries while it's held FAILS FAST with a clear message instead of corrupting the tree.
|
||||
#
|
||||
# This is the local-dev form of IDEA-FORGEJO-CICD-BUILD-LOCK-MULTIWRITER (the CI/CD concurrency mutex) — the
|
||||
# swarm hit the hazard before that gate, so the primitive is built now. Same contract: first-wins, second
|
||||
# fails clean, no race.
|
||||
#
|
||||
# USAGE: `bun run build` (package.json wires build -> this wrapper -> build:unlocked). Override the wait with
|
||||
# BUILD_LOCK_WAIT=<seconds> (default 0 = fail-fast/nonblock; set e.g. 300 to QUEUE behind a running build).
|
||||
set -euo pipefail
|
||||
|
||||
LOCK_FILE="${BUILD_LOCK_FILE:-public/.build.lock}"
|
||||
WAIT="${BUILD_LOCK_WAIT:-0}"
|
||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||
|
||||
# fd 9 = the lock handle
|
||||
exec 9>"$LOCK_FILE"
|
||||
|
||||
if [ "$WAIT" = "0" ]; then
|
||||
# nonblocking: first-wins, second fails clean
|
||||
if ! flock -n 9; then
|
||||
echo "🚫 BUILD LOCK HELD — another build is running (holder: $(cat "$LOCK_FILE" 2>/dev/null || echo '?'))." >&2
|
||||
echo " Rebuild refused to avoid a concurrent-build race (would clobber public/). Wait for it to finish" >&2
|
||||
echo " and retry, or run with BUILD_LOCK_WAIT=300 to QUEUE behind it." >&2
|
||||
exit 75 # EX_TEMPFAIL — 'try again later', distinct from a real build failure
|
||||
fi
|
||||
else
|
||||
echo "⏳ waiting up to ${WAIT}s for the build lock…" >&2
|
||||
if ! flock -w "$WAIT" 9; then
|
||||
echo "🚫 BUILD LOCK still held after ${WAIT}s — giving up (no race caused)." >&2
|
||||
exit 75
|
||||
fi
|
||||
fi
|
||||
|
||||
# we hold the lock — stamp who + when, then build
|
||||
echo "pid=$$ started=$(date -u +%FT%TZ) by=${BUILD_LOCK_OWNER:-$(whoami)}" >&9
|
||||
echo "🔒 build lock acquired — running the authoritative build." >&2
|
||||
bun run build:unlocked
|
||||
status=$?
|
||||
echo "🔓 build done (exit $status) — releasing lock." >&2
|
||||
exit $status
|
||||
|
|
@ -40,12 +40,45 @@
|
|||
*
|
||||
* Usage: node internal/generate-gallery.mjs (`bun run gallery` — last step of `bun run build`)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { PROFILE_TIER, TYPE_DISPLAY, detectProfileType } from './plugin-gallery/discover.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// Composition tier → capability number (5 = app-shell/nested-mfe-spa … 1 = single-element).
|
||||
// The plugin studio is itself the nested-mfe-spa (capability 5) — the reserved app-shell key.
|
||||
// cap = 5 - PROFILE_TIER (nested-mfe-spa:0→5 · nested-elements-mfe:1→4 · monolithic:2→3 ·
|
||||
// parameterized:3→2 · single:4→1). Unknown → 1 (single, the safe floor).
|
||||
export const CAP_OF = (type) => (PROFILE_TIER[type] != null ? 5 - PROFILE_TIER[type] : 1)
|
||||
|
||||
// Build-mode: the manifest carries no composition tier, so read it from source the way the
|
||||
// dev discovery does (explicit <profile>.meta.json → an *.instances.json means parameterized →
|
||||
// single). Returns { "<bundle>/<profile>": "<machine key>" }. Pure best-effort: a missing tree
|
||||
// or unreadable folder just yields {} and every profile falls back to capability 1.
|
||||
export function readSourceTiers(appDir = join(ROOT, 'plugin-designs')) {
|
||||
const out = {}
|
||||
if (!existsSync(appDir)) return out
|
||||
let dirs = []
|
||||
try { dirs = readdirSync(appDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return out }
|
||||
for (const bundle of dirs) {
|
||||
const dir = join(appDir, bundle)
|
||||
let files = []
|
||||
try { files = readdirSync(dir) } catch { continue }
|
||||
const hasInstances = files.some((f) => /\.instances\.json$/i.test(f))
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.vue')) continue
|
||||
const profile = f.replace(/\.vue$/, '')
|
||||
try {
|
||||
const t = detectProfileType(dir, profile, '', [], hasInstances)
|
||||
out[bundle + '/' + profile] = t.type
|
||||
} catch { /* leave unlisted → capability 1 */ }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Same fields the tree generator bakes into every leaf page (vite.doxa-maps-build-config.json).
|
||||
export function readBuildConfig() {
|
||||
let cfg = {}
|
||||
|
|
@ -82,11 +115,22 @@ function snip(b, p, x) {
|
|||
// Manifest → compact registry: [{b,s,i,e,p:[{p,e,u,d,em?,in?:[{id,t,a,pr,e,u,em?}]}]}].
|
||||
// lean = drop every embed snippet the page can re-derive with snip() (the baked fallback);
|
||||
// the live manifest keeps its snippets verbatim.
|
||||
export function compact(m, lean) {
|
||||
// tierMap (build mode): { "<bundle>/<profile>": "<machine key>" } from readSourceTiers, used
|
||||
// when the manifest profile carries no `type`. c = capability number (1–5); the SPA groups
|
||||
// and sorts by it (5 → 1). Dev mode passes profiles that already carry `sp.type`.
|
||||
export function compact(m, lean, tierMap) {
|
||||
tierMap = tierMap || {}
|
||||
// Composition tier → capability number, self-contained so the browser copy of this function
|
||||
// (shipped via src(compact) and re-run on the live manifest) needs no import. 5 = app-shell
|
||||
// (nested-mfe-spa) … 1 = single. The model may bake `sp.type`; else tierMap resolves it.
|
||||
var PT = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4 }
|
||||
var capFor = function (t) { return PT[t] != null ? 5 - PT[t] : 1 }
|
||||
return (m.bundles || []).map(function (src) {
|
||||
var b = { b: src.bundle, s: src.scriptUrl || '', i: src.integrity || '', e: src.elements || [], p: [] }
|
||||
b.p = (src.profiles || []).map(function (sp) {
|
||||
var p = { p: sp.profile, e: sp.element || '', u: sp.iframeUrl || '', d: sp.description || '' }
|
||||
var type = sp.type || tierMap[src.bundle + '/' + sp.profile] || 'single'
|
||||
var c = capFor(type)
|
||||
var p = { p: sp.profile, e: sp.element || '', u: sp.iframeUrl || '', d: sp.description || '', c: c }
|
||||
var em = sp.embed || {}, d = snip(b, p, null)
|
||||
if (!lean || em.iframe !== d.iframe || em.script !== d.script) p.em = em
|
||||
var ins = (sp.instances || []).map(function (sx) {
|
||||
|
|
@ -125,7 +169,7 @@ const LADDER_CSS = String.raw`:root{--acc:#1a73e8}@media(prefers-color-scheme:da
|
|||
.explain .ex{color:var(--mut);font-size:12px}
|
||||
@media(max-width:1280px){.ladder{display:none}}
|
||||
`
|
||||
const LADDER_HTML = '<nav class="ladder" id="ladder" aria-label="Composability ladder"></nav>'
|
||||
const LADDER_HTML = '<nav class="ladder" id="ladder" aria-label="Composability ladder" v-pre></nav>'
|
||||
const LADDER_JS = String.raw`<script>
|
||||
(function () {
|
||||
var PRIMER = [
|
||||
|
|
@ -196,9 +240,9 @@ const LADDER_JS = String.raw`<script>
|
|||
* config fields every leaf page bakes (readBuildConfig)
|
||||
*/
|
||||
export function renderGallery(model, opts = {}) {
|
||||
const { mode = 'build', title = 'doxa-maps', tree = 'doxa-maps', tokenUrl = 'window.MAP_TOKEN_URL', token = '', apiBaseUrl = '', assetsBaseUrl = '', qaWidget = true } = opts
|
||||
const { mode = 'build', title = 'doxa-maps', tree = 'doxa-maps', tokenUrl = 'window.MAP_TOKEN_URL', token = '', apiBaseUrl = '', assetsBaseUrl = '', qaWidget = true, tierMap = {} } = opts
|
||||
const dev = mode === 'dev'
|
||||
const baked = compact(model, true)
|
||||
const baked = compact(model, true, tierMap)
|
||||
const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c')
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
// The one line the two modes load a bundle with: the built IIFE (SRI-checked) vs the dev entry.
|
||||
|
|
@ -241,19 +285,51 @@ code{font-family:ui-monospace,Menlo,monospace}
|
|||
.hint{display:grid;place-items:center;padding:20px;text-align:center;color:var(--mut);font-size:13px}
|
||||
.hint.err{color:var(--err)}
|
||||
.hint input{font:12px ui-monospace,Menlo,monospace;padding:6px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--fg);width:min(320px,70vw)}
|
||||
/* Capability tiers (5 → 1): the vertical-tab sections of the nested-mfe-spa sidebar. */
|
||||
.tier{padding:12px 14px 2px;display:flex;align-items:baseline;gap:7px}
|
||||
.tier .cap{font:700 10px/1 system-ui,sans-serif;letter-spacing:.5px;color:var(--bg);background:var(--mut);border-radius:999px;padding:3px 7px}
|
||||
.tier .tl{font-weight:600;font-size:12px}.tier .td{color:var(--mut);font-size:11px;margin-left:auto}
|
||||
#app{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
|
||||
[hidden]{display:none!important}
|
||||
@media(max-width:700px){.body{flex-direction:column}nav{flex:0 0 auto;max-height:45vh;border-right:0;border-bottom:1px solid var(--bd)}#menu{display:inline-block}}
|
||||
${dev ? LADDER_CSS : ''}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>${esc(title)}</b><span class="n" id="count"></span>${dev ? LADDER_HTML : ''}<button id="menu" type="button" aria-controls="nav" aria-expanded="true">Maps</button></header>
|
||||
<!-- The doxa-maps nested-mfe-spa (capability 5, the reserved app-shell): a Vue single-page app
|
||||
whose sidebar is the composability ladder rendered as vertical tabs (capability 5 → 1),
|
||||
each row mounting that plugin's custom element in the one slot. This SAME page is the
|
||||
served studio root (public/index.html) AND the Capacitor mobile-app entry (webDir: public).
|
||||
Vue drives the shell; the map elements mount imperatively into #slot (they own their DOM). -->
|
||||
<div id="app">
|
||||
<header>
|
||||
<b>${esc(title)}</b><span class="n">{{ countLabel }}</span>${dev ? LADDER_HTML : ''}
|
||||
<button id="menu" type="button" aria-controls="nav" aria-expanded="true" @click="navOpen = !navOpen">Maps</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav id="nav" aria-label="Maps"></nav>
|
||||
<main>
|
||||
<div class="bar"><div class="t"><b id="ttl">Choose a map</b><span class="s" id="sub"></span></div><a class="btn" id="open" target="_blank" rel="noopener" hidden>Open page</a><button id="emb" type="button" hidden aria-expanded="false">Copy embed</button></div>
|
||||
<div id="embed" hidden><label>iframe embed <button type="button" data-copy="ifr">Copy</button></label><textarea id="ifr" readonly></textarea><div id="scrw"><label>script embed <button type="button" data-copy="scr">Copy</button></label><textarea id="scr" readonly></textarea></div></div>
|
||||
<div id="slot"><div class="hint">Choose a map from the list.</div></div>
|
||||
</main>
|
||||
<nav id="nav" aria-label="Maps · sorted by capability 5 → 1" v-show="navOpen">
|
||||
<div class="grp" v-for="grp in groups" :key="grp.cap">
|
||||
<div class="tier"><span class="cap">CAP {{ grp.cap }}</span><span class="tl">{{ grp.label }}</span><span class="td">{{ grp.rows.length }}</span></div>
|
||||
<button v-for="r in grp.rows" :key="r.key" type="button" class="row" :class="{ in: r.kind === 'instance' }"
|
||||
:aria-current="String(activeKey === r.key)" @click="go(r.key)">
|
||||
<span>{{ r.label }}</span><code><{{ r.tag }}></code>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="bar">
|
||||
<div class="t"><b>{{ ttl }}</b><span class="s">{{ sub }}</span></div>
|
||||
<a class="btn" :href="openHref" target="_blank" rel="noopener" v-show="openHref">Open page</a>
|
||||
<button type="button" v-show="hasSel" :aria-expanded="String(embedOpen)" @click="embedOpen = !embedOpen">Copy embed</button>
|
||||
</div>
|
||||
<div id="embed" v-show="embedOpen && hasSel">
|
||||
<label>iframe embed <button type="button" @click="copyText(embedIframe, $event.target)">Copy</button></label>
|
||||
<textarea readonly :value="embedIframe"></textarea>
|
||||
<div v-show="embedScript"><label>script embed <button type="button" @click="copyText(embedScript, $event.target)">Copy</button></label>
|
||||
<textarea readonly :value="embedScript"></textarea></div>
|
||||
</div>
|
||||
<div id="slot" ref="slot"><div class="hint">Choose a map from the list.</div></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
|
|
@ -269,7 +345,11 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
maplibre: { css: ['https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css'], js: ['https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js'] }
|
||||
};
|
||||
var $ = function (id) { return document.getElementById(id) };
|
||||
var nav = $('nav'), slot = $('slot'), ttl = $('ttl'), sub = $('sub'), open = $('open'), embBtn = $('emb'), embed = $('embed'), ifr = $('ifr'), scr = $('scr'), scrw = $('scrw'), menu = $('menu'), count = $('count');
|
||||
// The map custom elements mount imperatively into this slot (they own their own DOM and must
|
||||
// not be re-rendered by Vue); the Vue app writes its ref here after mount. Falls back to the
|
||||
// element id so the loader works even before the app boots.
|
||||
var slotEl = function () { return (window.__spa && window.__spa.slot) || $('slot') };
|
||||
var TIER_MAP = ${json(tierMap)}; // "<bundle>/<profile>" → composition tier, for live re-compact
|
||||
// THIS PAGE'S OWN DIRECTORY. A host may serve the folder at /…/doxa-maps-build (no trailing
|
||||
// slash); the browser would then resolve every relative ref against the PARENT directory and
|
||||
// drop the folder name, so bundles 404. Derive the directory explicitly and build refs from it.
|
||||
|
|
@ -285,7 +365,7 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
// anchored to BASE (above) so they resolve the same with or without a trailing slash.
|
||||
function rel(u) { return /^([a-z]+:|\/)/i.test(u) ? u : BASE + TREE + '/' + u.replace(/^\.\//, '') }
|
||||
function qp(name) { try { return new URLSearchParams(location.search).get(name) || '' } catch (e) { return '' } }
|
||||
function hint(msg, err) { var d = document.createElement('div'); d.className = 'hint' + (err ? ' err' : ''); d.textContent = msg; slot.innerHTML = ''; slot.appendChild(d); return d }
|
||||
function hint(msg, err) { var slot = slotEl(); var d = document.createElement('div'); d.className = 'hint' + (err ? ' err' : ''); d.textContent = msg; slot.innerHTML = ''; slot.appendChild(d); return d }
|
||||
function assign(t) { for (var i = 1; i < arguments.length; i++) { var s = arguments[i]; for (var k in s) if (Object.prototype.hasOwnProperty.call(s, k)) t[k] = s[k] } return t }
|
||||
|
||||
function once(url, kind, attrs) {
|
||||
|
|
@ -306,28 +386,7 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
}
|
||||
${loader}
|
||||
|
||||
function row(key, kind, label, tag, extra) {
|
||||
var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'row' + (extra || ''); btn.setAttribute('data-k', key); btn.setAttribute('data-kind', kind);
|
||||
var l = document.createElement('span'); l.textContent = label; btn.appendChild(l);
|
||||
var c = document.createElement('code'); c.textContent = '<' + tag + '>'; btn.appendChild(c);
|
||||
btn.onclick = function () { location.hash = '#/' + key.split('/').map(encodeURIComponent).join('/') };
|
||||
return btn;
|
||||
}
|
||||
function draw() {
|
||||
nav.innerHTML = ''; var np = 0, ni = 0;
|
||||
REG.forEach(function (b) {
|
||||
var g = document.createElement('div'); g.className = 'grp';
|
||||
var h = document.createElement('div'); h.className = 'gh'; var hn = document.createElement('span'); hn.textContent = b.b; h.appendChild(hn);
|
||||
var hc = document.createElement('span'); hc.textContent = b.p.length; h.appendChild(hc); g.appendChild(h);
|
||||
b.p.forEach(function (p) {
|
||||
np++; g.appendChild(row(b.b + '/' + p.p, 'profile', p.p, p.e || b.e[0] || b.b));
|
||||
(p.in || []).forEach(function (x) { ni++; g.appendChild(row(b.b + '/' + p.p + '/' + x.id, 'instance', x.t, x.e || p.e || b.e[0] || b.b, ' in')) });
|
||||
});
|
||||
nav.appendChild(g);
|
||||
});
|
||||
count.textContent = REG.length + ' bundles · ' + np + ' profiles · ' + ni + ' instances';
|
||||
}
|
||||
|
||||
// Resolve a deep-link key ("<bundle>/<profile>[/<instance>]") against the live registry.
|
||||
function find(parts) {
|
||||
for (var i = 0; i < REG.length; i++) if (REG[i].b === parts[0]) {
|
||||
var b = REG[i];
|
||||
|
|
@ -339,20 +398,6 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function select(key) {
|
||||
var parts = key.split('/').map(function (s) { try { return decodeURIComponent(s) } catch (e) { return s } });
|
||||
var hit = find(parts);
|
||||
var rows = nav.querySelectorAll('.row');
|
||||
for (var i = 0; i < rows.length; i++) rows[i].setAttribute('aria-current', rows[i].getAttribute('data-k') === parts.join('/') ? 'true' : 'false');
|
||||
if (!hit) { ttl.textContent = 'No such map'; sub.textContent = key; open.hidden = embBtn.hidden = embed.hidden = true; hint('No such map: ' + key, true); return }
|
||||
var b = hit.b, p = hit.p, x = hit.x, tag = (x && x.e) || p.e || b.e[0] || b.b;
|
||||
ttl.textContent = x ? x.t : p.p; sub.textContent = b.b + ' · <' + tag + '>' + (p.d ? ' · ' + p.d : '');
|
||||
open.href = rel((x || p).u); open.hidden = false; embBtn.hidden = false; embed.hidden = true; embBtn.setAttribute('aria-expanded', 'false');
|
||||
var em = x ? (x.em || snip(b, p, x)) : (p.em || snip(b, p, null));
|
||||
ifr.value = em.iframe || ''; scr.value = em.script || ''; scrw.hidden = !em.script;
|
||||
if (narrow()) { nav.hidden = true; menu.setAttribute('aria-expanded', 'false') }
|
||||
mount(b, p, x, tag);
|
||||
}
|
||||
|
||||
// Same profile-config the leaf page bakes (profile page: profile + dataSource + instanceId;
|
||||
// instance page: its props), plus the host-supplied share URL of that leaf page.
|
||||
|
|
@ -370,7 +415,7 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
try {
|
||||
var c = token ? assign({}, cfg, { tk: token }) : cfg;
|
||||
var el = document.createElement(tag); el.setAttribute('profile-config', JSON.stringify(c));
|
||||
slot.innerHTML = ''; slot.appendChild(el);
|
||||
var slot = slotEl(); slot.innerHTML = ''; slot.appendChild(el);
|
||||
} catch (e) { hint(e && e.message ? e.message : String(e), true) }
|
||||
});
|
||||
}).catch(function (e) { if (my === seq) hint('Failed to load ' + b.b + ': ' + (e && e.message ? e.message : e), true) });
|
||||
|
|
@ -412,25 +457,127 @@ ${dev ? LADDER_CSS : ''}</style>
|
|||
fetchToken(0, []);
|
||||
}
|
||||
|
||||
function copy(text, btn) {
|
||||
function copyText(text, btn) {
|
||||
var ok = function () { var t = btn.textContent; btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = t }, 1200) };
|
||||
var fallback = function () { var ta = btn.parentNode.nextSibling; ta.focus(); ta.select(); try { document.execCommand('copy'); ok() } catch (e) {} };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, fallback); else fallback();
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () {}); else {
|
||||
try { var ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch (e) {}
|
||||
}
|
||||
}
|
||||
embed.addEventListener('click', function (e) { var b = e.target.closest ? e.target.closest('[data-copy]') : null; if (b) copy($(b.getAttribute('data-copy')).value, b) });
|
||||
embBtn.onclick = function () { embed.hidden = !embed.hidden; embBtn.setAttribute('aria-expanded', String(!embed.hidden)) };
|
||||
menu.onclick = function () { nav.hidden = !nav.hidden; menu.setAttribute('aria-expanded', String(!nav.hidden)) };
|
||||
try { window.matchMedia('(max-width:700px)').addEventListener('change', function (e) { if (!e.matches) { nav.hidden = false; menu.setAttribute('aria-expanded', 'true') } }) } catch (e) {}
|
||||
|
||||
function route() { var h = (location.hash || '').replace(/^#\/?/, ''); if (h) select(h) }
|
||||
function init(m) {
|
||||
if (m) { try { REG = compact(m, false) } catch (e) { REG = BAKED } }
|
||||
draw(); window.addEventListener('hashchange', route); route();
|
||||
// ── The capability ladder for the sidebar headings (5 → 1), reader-facing names ──
|
||||
var CAP_LABEL = { 5: 'App shell · nested-mfe-spa', 4: 'Composite element', 3: 'Multi-view element', 2: 'Preset element', 1: 'Single element' };
|
||||
// REG → tab groups keyed by capability, most-composed first (5 → 1). Each group lists every
|
||||
// profile (and its preset instances) as one row; a bundle contributes rows to the tier of each
|
||||
// profile it holds, so one bundle can appear under several capability headings.
|
||||
function toGroups() {
|
||||
var byCap = {};
|
||||
REG.forEach(function (b) {
|
||||
(b.p || []).forEach(function (p) {
|
||||
var cap = p.c || 1, tag = p.e || b.e[0] || b.b;
|
||||
(byCap[cap] = byCap[cap] || []).push({ key: b.b + '/' + p.p, kind: 'profile', label: p.p, tag: tag, bundle: b.b });
|
||||
(p.in || []).forEach(function (x) {
|
||||
byCap[cap].push({ key: b.b + '/' + p.p + '/' + x.id, kind: 'instance', label: x.t, tag: x.e || tag, bundle: b.b });
|
||||
});
|
||||
});
|
||||
});
|
||||
return [5, 4, 3, 2, 1].filter(function (c) { return byCap[c] && byCap[c].length }).map(function (c) {
|
||||
return { cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] };
|
||||
});
|
||||
}
|
||||
|
||||
function bootVue(Vue) {
|
||||
var app = Vue.createApp({
|
||||
data: function () {
|
||||
return {
|
||||
groups: toGroups(), activeKey: '', navOpen: true, embedOpen: false,
|
||||
ttl: 'Choose a map', sub: '', openHref: '', hasSel: false, embedIframe: '', embedScript: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
countLabel: function () {
|
||||
var nb = REG.length, np = 0, ni = 0;
|
||||
REG.forEach(function (b) { np += (b.p || []).length; (b.p || []).forEach(function (p) { ni += (p.in || []).length }) });
|
||||
return nb + ' bundles · ' + np + ' profiles · ' + ni + ' instances';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
go: function (key) { location.hash = '#/' + key.split('/').map(encodeURIComponent).join('/') },
|
||||
copyText: copyText,
|
||||
rebuild: function (m) { if (m) { try { REG = compact(m, false, TIER_MAP) } catch (e) { REG = BAKED } } this.groups = toGroups(); this.route(); },
|
||||
route: function () { var h = (location.hash || '').replace(/^#\/?/, ''); if (h) this.select(h); },
|
||||
select: function (key) {
|
||||
var parts = key.split('/').map(function (s) { try { return decodeURIComponent(s) } catch (e) { return s } });
|
||||
var hit = find(parts); this.activeKey = parts.join('/');
|
||||
if (!hit) { this.ttl = 'No such map'; this.sub = key; this.openHref = ''; this.hasSel = false; this.embedOpen = false; hint('No such map: ' + key, true); return; }
|
||||
var b = hit.b, p = hit.p, x = hit.x, tag = (x && x.e) || p.e || b.e[0] || b.b;
|
||||
this.ttl = x ? x.t : p.p; this.sub = b.b + ' · <' + tag + '>' + (p.d ? ' · ' + p.d : '');
|
||||
this.openHref = rel((x || p).u); this.hasSel = true; this.embedOpen = false;
|
||||
var em = x ? (x.em || snip(b, p, x)) : (p.em || snip(b, p, null));
|
||||
this.embedIframe = em.iframe || ''; this.embedScript = em.script || '';
|
||||
if (narrow()) this.navOpen = false;
|
||||
mount(b, p, x, tag);
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
var self = this;
|
||||
window.__spa = { slot: this.$refs.slot, app: this };
|
||||
window.addEventListener('hashchange', function () { self.route() });
|
||||
try { window.matchMedia('(max-width:700px)').addEventListener('change', function (e) { if (!e.matches) self.navOpen = true }) } catch (e) {}
|
||||
// Live manifest first (the page stays right when bundles change); baked copy is the fallback.
|
||||
if (typeof fetch === 'function' && location.protocol !== 'file:') {
|
||||
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null })
|
||||
.then(function (m) { self.rebuild(m) }, function () { self.route() });
|
||||
} else { self.route(); }
|
||||
}
|
||||
});
|
||||
app.config.compilerOptions.isCustomElement = function () { return true }; // map tags are custom elements
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
// Vue drives the shell (the nested-mfe-spa). Self-hosted first (portable / offline / no-CDN),
|
||||
// then a CDN fallback; if neither loads, degrade to a plain rendered list so the page never
|
||||
// blanks. once() resolves the tree-relative ASSETS path the same way every leaf page does.
|
||||
function loadVue() {
|
||||
if (window.Vue) return Promise.resolve(window.Vue);
|
||||
var local = ASSETS + '/vendor/vue/vue.global.prod.js';
|
||||
var cdn = 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js';
|
||||
return once(local, 'js').then(function () { return window.Vue }, function () {
|
||||
return once(cdn, 'js').then(function () { return window.Vue });
|
||||
});
|
||||
}
|
||||
loadVue().then(function (Vue) {
|
||||
if (Vue) bootVue(Vue);
|
||||
else fallbackRender();
|
||||
}).catch(function () { fallbackRender(); });
|
||||
|
||||
// No-Vue degrade: render the same capability-sorted list with plain DOM so a map is still
|
||||
// reachable (parse-safe, dependency-free). Rare — Vue is vendored in the tree.
|
||||
function fallbackRender() {
|
||||
var host = $('app'); if (!host) return;
|
||||
function paint() {
|
||||
var nav = host.querySelector('#nav'); if (!nav) return;
|
||||
nav.innerHTML = '';
|
||||
toGroups().forEach(function (g) {
|
||||
var h = document.createElement('div'); h.className = 'tier'; h.textContent = 'CAP ' + g.cap + ' · ' + g.label; nav.appendChild(h);
|
||||
g.rows.forEach(function (r) {
|
||||
var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'row' + (r.kind === 'instance' ? ' in' : '');
|
||||
btn.textContent = r.label; btn.onclick = function () { location.hash = '#/' + r.key.split('/').map(encodeURIComponent).join('/') };
|
||||
nav.appendChild(btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
function route() {
|
||||
var h = (location.hash || '').replace(/^#\/?/, ''); if (!h) return;
|
||||
var parts = h.split('/').map(function (s) { try { return decodeURIComponent(s) } catch (e) { return s } });
|
||||
var hit = find(parts); if (hit) mount(hit.b, hit.p, hit.x, (hit.x && hit.x.e) || hit.p.e || hit.b.e[0] || hit.b.b); else hint('No such map: ' + h, true);
|
||||
}
|
||||
window.__spa = { slot: $('slot') };
|
||||
if (typeof fetch === 'function' && location.protocol !== 'file:') {
|
||||
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null })
|
||||
.then(function (m) { if (m) { try { REG = compact(m, false, TIER_MAP) } catch (e) { REG = BAKED } } paint(); route(); }, function () { paint(); route(); });
|
||||
} else { paint(); route(); }
|
||||
window.addEventListener('hashchange', route);
|
||||
}
|
||||
// Live manifest first (the page stays right when bundles change); the baked copy is the fallback.
|
||||
if (typeof fetch === 'function' && location.protocol !== 'file:') {
|
||||
fetch(TREE + '/manifest.json', { cache: 'no-cache' }).then(function (r) { return r.ok ? r.json() : null }).then(init, function () { init(null) });
|
||||
} else init(null);
|
||||
})();
|
||||
</script>
|
||||
${dev ? LADDER_JS : ''}</body>
|
||||
|
|
@ -444,8 +591,9 @@ function main() {
|
|||
const manifestPath = join(ROOT, 'public', CFG.name, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) { console.error(`[generate-gallery] missing ${manifestPath} — run \`bun run tree\` first`); process.exit(1) }
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const html = renderGallery(manifest, { mode: 'build', title: CFG.title, tree: CFG.name, tokenUrl: CFG.tokenUrl, token: CFG.token, apiBaseUrl: CFG.apiBaseUrl, assetsBaseUrl: CFG.assetsBaseUrl, qaWidget: CFG.qaWidget })
|
||||
const baked = compact(manifest, true)
|
||||
const tierMap = readSourceTiers() // "<bundle>/<profile>" → composition tier (the manifest carries none)
|
||||
const html = renderGallery(manifest, { mode: 'build', title: CFG.title, tree: CFG.name, tokenUrl: CFG.tokenUrl, token: CFG.token, apiBaseUrl: CFG.apiBaseUrl, assetsBaseUrl: CFG.assetsBaseUrl, qaWidget: CFG.qaWidget, tierMap })
|
||||
const baked = compact(manifest, true, tierMap)
|
||||
const nProfiles = baked.reduce((n, b) => n + b.p.length, 0)
|
||||
const nInstances = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.in ? p.in.length : 0), 0), 0)
|
||||
const nOverrides = baked.reduce((n, b) => n + b.p.reduce((k, p) => k + (p.em ? 1 : 0) + (p.in || []).filter(x => x.em).length, 0), 0)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,20 @@ function extractHeaderTags(filePath) {
|
|||
return tags
|
||||
}
|
||||
|
||||
// A leaf DRAWS A MAP iff its .vue references a map engine. This is the signal that a page
|
||||
// needs a map peer (Mapbox/MapLibre) + the Mapbox token ladder; a leaf without it is a grid,
|
||||
// card, chart-table or data-list and must render TOKEN-FREE with NO peer (see mapPageHtml).
|
||||
// UNLIKE composite-TIER classification (which the codebase refuses to infer from source, see
|
||||
// discover.detectProfileType), this is an unambiguous POSITIVE dependency check, not a
|
||||
// heuristic tag guess: a file that imports useMapInstance / touches window.mapboxgl / loads a
|
||||
// mapbox-gl|maplibre-gl peer unquestionably intends to draw tiles. Whole-file scan (not just
|
||||
// the header) because these references live in the <script setup> body, not the JSDoc header.
|
||||
const MAP_ENGINE_RE = /useMapInstance|useMapProvider|mapbox-gl|maplibre-gl|window\.mapboxgl|window\.maplibregl/
|
||||
function isMapLeaf(filePath) {
|
||||
try { return MAP_ENGINE_RE.test(readFileSync(filePath, 'utf8')) }
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
/** Element tag names a bundle registers, read from its index.js (order preserved). */
|
||||
function extractElements(indexJsPath) {
|
||||
if (!existsSync(indexJsPath)) return []
|
||||
|
|
@ -380,7 +394,7 @@ function bundleHtml(b, index, manifest) {
|
|||
* opts.bakedToken opt-in literal token baked from build-config (ladder rung 3)
|
||||
*/
|
||||
function mapPageHtml(opts) {
|
||||
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity } = opts
|
||||
const { title, tag, config, jsSrc, apiBaseUrl, tokenUrlExpr, bakedToken, integrity, shellTokenFree, isMap } = opts
|
||||
const apiScript = apiBaseUrl
|
||||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
|
|
@ -391,7 +405,38 @@ function mapPageHtml(opts) {
|
|||
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
|
||||
// immediately. Default stays Mapbox, byte-for-byte unchanged.
|
||||
const isMapLibre = !!(config && config.provider === 'maplibre')
|
||||
const peerBlock = isMapLibre
|
||||
// DOES THIS LEAF ACTUALLY DRAW A MAP? (isMap, computed per-profile at scan time — a
|
||||
// leaf whose .vue references a map engine: useMapInstance/useMapProvider, mapbox-gl,
|
||||
// maplibre-gl, or window.mapboxgl/maplibregl. See scanBundles → isMapLeaf.)
|
||||
// A leaf that is NOT a map (a grid, a card, a chart table, a data list) must render
|
||||
// TOKEN-FREE and load NO map peer at all — otherwise a tokenless load hits the token
|
||||
// ladder, fetch-fails on the token endpoint (e.g. CORS on doxa.life/api/maps/token)
|
||||
// and NEVER creates the element: the styled #slot stays empty, so maps/dashboards look
|
||||
// broken to a first-time viewer. This is the CORRECT signal that supersedes the legacy
|
||||
// provider:'maplibre' hack authors used to force non-maps token-free (people-groups-grid,
|
||||
// doxa-vision-card, big-picture). `isMap` defaults to true when unspecified so any
|
||||
// caller that has not yet computed it keeps the original map-gated behaviour.
|
||||
const drawsMap = isMap === undefined ? true : !!isMap
|
||||
const notAMap = !drawsMap
|
||||
// SHELL PAGES ARE TOKEN-FREE. A cap-5 app-shell (nested-mfe-spa, e.g. <plugin-studio>) is
|
||||
// NOT a map — it is chrome that nests OTHER elements. Its nested MAP elements each render in
|
||||
// their own iframe leaf page, which runs its OWN token ladder. So the shell page itself must
|
||||
// NOT gate element creation on a Mapbox token: without this, a tokenless load fetch-fails on
|
||||
// the token endpoint (e.g. CORS on doxa.life/api/maps/token) and NEVER creates the element —
|
||||
// the styled ladder never appears. Token-free ⇒ mount immediately + load NO Mapbox peer.
|
||||
// shellOnly WINS over provider: a cap-5 app-shell paints no map itself, so it needs no peer
|
||||
// AT ALL — even if an instance carries provider:'maplibre' (a legacy way authors forced
|
||||
// token-free before shellTokenFree existed). Correct signal = the composition tier, not a
|
||||
// map provider that happens to be set on the shell's config. `notAMap` generalises this to
|
||||
// EVERY non-map leaf (cap-4 composites + cap-1/2/3 non-map leaves), not just cap-5 shells.
|
||||
const shellOnly = !!shellTokenFree || notAMap
|
||||
const tokenFree = isMapLibre || shellOnly
|
||||
const peerBlock = shellOnly
|
||||
? `<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->`
|
||||
: isMapLibre
|
||||
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
|
||||
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
|
@ -425,7 +470,7 @@ function mapPageHtml(opts) {
|
|||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">${isMapLibre ? 'loading map…' : 'loading token…'}</div>
|
||||
<div id="status">${tokenFree ? 'loading…' : 'loading token…'}</div>
|
||||
${apiScript}
|
||||
<!-- Bundle IIFE — registers the <${esc(tag)}> custom element. -->
|
||||
<script src="${esc(jsSrc)}"${integrity ? ` integrity="${esc(integrity)}" crossorigin="anonymous"` : ''}></script>
|
||||
|
|
@ -445,8 +490,9 @@ ${apiScript}
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
var TOKEN_FREE = ${JSON.stringify(isMapLibre)};
|
||||
// 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 = ${JSON.stringify(tokenFree)};
|
||||
var TOKEN_SRC = ${JSON.stringify(tokenUrlExpr)};
|
||||
var BAKED_TOKEN = ${JSON.stringify(bakedToken || '')};
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
|
@ -567,6 +613,11 @@ function profileHtml(b, profile, tokenUrlExpr, bakedToken) {
|
|||
tokenUrlExpr,
|
||||
bakedToken,
|
||||
integrity: b.integrity,
|
||||
// A cap-5 app-shell (nested-mfe-spa) is chrome, not a map → token-free (see mapPageHtml).
|
||||
shellTokenFree: profile.type === 'nested-mfe-spa',
|
||||
// Whether this leaf actually draws map tiles (computed in scanBundles → isMapLeaf). A
|
||||
// non-map leaf renders token-free with no map peer; only a real map runs the token ladder.
|
||||
isMap: profile.isMap,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -586,6 +637,11 @@ function instanceHtml(b, profile, inst, tokenUrlExpr, bakedToken) {
|
|||
tokenUrlExpr,
|
||||
bakedToken,
|
||||
integrity: b.integrity,
|
||||
// Instance pages inherit the profile's shell-ness (an app-shell preset is still token-free).
|
||||
shellTokenFree: profile.type === 'nested-mfe-spa',
|
||||
// Instance pages inherit the profile's map-ness: a preset of a non-map leaf is still a
|
||||
// non-map (token-free, no peer); a preset of a real map still runs the token ladder.
|
||||
isMap: profile.isMap,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -703,12 +759,21 @@ function scanBundles() {
|
|||
// an *.instances.json → parameterized; else single. (detectProfileType, same as the gallery.)
|
||||
const { type: profileType } = detectProfileType(dir, profileName, tag, [], instances.length > 0)
|
||||
|
||||
// Does this leaf actually draw map tiles? Composite tiers (cap-4 nested-elements-mfe,
|
||||
// cap-5 nested-mfe-spa) are chrome that nests OTHER elements — they never paint a map
|
||||
// themselves (any real map is its own iframe leaf), so they are non-maps regardless of
|
||||
// source. Leaf tiers (single / parameterized / monolithic) are a map only if their .vue
|
||||
// references a map engine. This drives the token-gate/peer decision in mapPageHtml.
|
||||
const composite = profileType === 'nested-elements-mfe' || profileType === 'nested-mfe-spa'
|
||||
const isMap = composite ? false : isMapLeaf(join(dir, file))
|
||||
|
||||
return {
|
||||
profile: profileName,
|
||||
file,
|
||||
description: description || fileTag || '',
|
||||
element: tag,
|
||||
type: profileType,
|
||||
isMap,
|
||||
iframeUrl,
|
||||
scriptUrl,
|
||||
embed: { iframe: iframeEmbed, script: scriptEmbed },
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ const args = process.argv.slice(2)
|
|||
const noToken = args.includes('--no-token')
|
||||
const wrapper = args.find(a => !a.startsWith('--')) || 'app-wrapper-material'
|
||||
|
||||
const src = join(ROOT, 'layouts', wrapper)
|
||||
const src = join(ROOT, 'library', 'layouts', wrapper)
|
||||
const dst = join(ROOT, 'public')
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.error(`[install-shell] FAIL — no such wrapper: layouts/${wrapper}`)
|
||||
console.error(`[install-shell] available: ${readdirSync(join(ROOT, 'layouts')).filter(f => !f.endsWith('.md')).join(', ')}`)
|
||||
console.error(`[install-shell] available: ${readdirSync(join(ROOT, 'library', 'layouts')).filter(f => !f.endsWith('.md')).join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(join(src, 'index.html'))) {
|
||||
|
|
@ -75,9 +75,9 @@ for (const entry of readdirSync(src)) {
|
|||
const shellSrc = readFileSync(join(src, 'index.html'), 'utf8')
|
||||
const nested = [...shellSrc.matchAll(/from\s+['"]\.\.\/([A-Za-z0-9._-]+)\//g)].map(m => m[1])
|
||||
for (const layout of new Set(nested)) {
|
||||
const from = join(ROOT, 'layouts', layout)
|
||||
const from = join(ROOT, 'library', 'layouts', layout)
|
||||
if (!existsSync(from)) {
|
||||
console.error(`[install-shell] FAIL — index.html imports ../${layout}/ but layouts/${layout} does not exist.`)
|
||||
console.error(`[install-shell] FAIL — index.html imports ../${layout}/ but library/layouts/${layout} does not exist.`)
|
||||
process.exit(1)
|
||||
}
|
||||
cpSync(from, join(dst, layout), { recursive: true })
|
||||
|
|
|
|||
|
|
@ -64,13 +64,16 @@ export function galleryModel(reg) {
|
|||
const bundles = [...reg].sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
|
||||
const scriptUrl = `/plugin-designs/${b.bundle}/index.js`
|
||||
const presets = new Map() // profile → its preset instances (empty for every other kind)
|
||||
const typeOf = new Map() // profile → composition tier machine key (single … nested-mfe-spa)
|
||||
for (const it of b.items) {
|
||||
if (!presets.has(it.profile)) presets.set(it.profile, [])
|
||||
if (!typeOf.has(it.profile)) typeOf.set(it.profile, it.type)
|
||||
if (it.type === 'parameterized') presets.get(it.profile).push(it)
|
||||
}
|
||||
const profiles = [...presets.keys()].sort().map((profile) => {
|
||||
const p = {
|
||||
profile, file: `${profile}.vue`, description: '', element: b.tag,
|
||||
type: typeOf.get(profile) || 'single',
|
||||
iframeUrl: link(b.bundle, profile), scriptUrl,
|
||||
embed: {
|
||||
iframe: iframe(`./${b.bundle}/${profile}/index.html`),
|
||||
|
|
|
|||
69
internal/readme-shots-fix.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
// Targeted re-capture: cap1 (vision-card WITH token so it renders, not the token-error state)
|
||||
// and cap5 (SPA deep-linked to a plugin so a map shows, not "choose a map").
|
||||
// Retries navigation while concurrent builds churn the tree.
|
||||
import { chromium } from 'playwright'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { join, extname, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(HERE, '..', 'public')
|
||||
const OUT = resolve(HERE, '..', 'docs', 'readme-images')
|
||||
const TYPES = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript',
|
||||
'.json':'application/json', '.css':'text/css', '.webp':'image/webp', '.png':'image/png',
|
||||
'.svg':'image/svg+xml', '.geojson':'application/json', '.csv':'text/csv', '.woff2':'font/woff2' }
|
||||
const server = createServer(async (req, res) => {
|
||||
try { let p = join(ROOT, decodeURIComponent(req.url.split('?')[0]))
|
||||
if ((await stat(p)).isDirectory()) p = join(p, 'index.html')
|
||||
res.writeHead(200, { 'content-type': TYPES[extname(p)] || 'application/octet-stream' })
|
||||
res.end(await readFile(p)) } catch { res.writeHead(404).end('not found') }
|
||||
})
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`
|
||||
let TK = ''
|
||||
try { const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
|
||||
TK = r.ok ? ((await r.json()).token || '') : '' } catch {}
|
||||
console.log(TK ? `token acquired (${TK.length})` : 'NO token')
|
||||
const browser = await chromium.launch()
|
||||
|
||||
async function tryShot(name, path, { vp = { width: 1440, height: 900 }, wait = 5000, dsf = 2, before = null, waitFor = null, tries = 8 } = {}) {
|
||||
for (let t = 1; t <= tries; t++) {
|
||||
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
|
||||
const page = await ctx.newPage()
|
||||
let ok = false
|
||||
// Insert tk into the QUERY, keeping any #hash fragment LAST (else the route breaks).
|
||||
const hashAt = path.indexOf('#')
|
||||
const basePath = hashAt >= 0 ? path.slice(0, hashAt) : path
|
||||
const frag = hashAt >= 0 ? path.slice(hashAt) : ''
|
||||
const url = BASE + basePath + (basePath.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK) + frag
|
||||
try {
|
||||
const resp = await page.goto(url, { waitUntil: 'load', timeout: 30000 })
|
||||
if (resp && resp.status() === 404) throw new Error('404')
|
||||
if (waitFor) { try { await page.waitForSelector(waitFor, { timeout: 12000 }) } catch {} }
|
||||
if (before) await before(page)
|
||||
await page.waitForTimeout(wait)
|
||||
await page.screenshot({ path: join(OUT, name + '.png') })
|
||||
ok = true
|
||||
console.log(' OK', name, `(try ${t})`)
|
||||
} catch (e) { console.log(' retry', name, t, e.message.split('\n')[0]) }
|
||||
finally { await ctx.close() }
|
||||
if (ok) return true
|
||||
await new Promise(r => setTimeout(r, 4000))
|
||||
}
|
||||
console.log(' GAVE UP', name)
|
||||
return false
|
||||
}
|
||||
|
||||
// cap1: vision-card DEFAULT instance page (TOKEN_FREE=true -> renders the card instantly, no token error).
|
||||
// Long wait so the aurora backdrop + rotating stat cards fully paint (they animate in).
|
||||
await tryShot('cap1-single-element', '/doxa-maps/doxa-vision-card/doxa-vision-card/default/index.html',
|
||||
{ wait: 9000 })
|
||||
|
||||
// cap5: SPA deep-linked to the prayer map instance so a real painted map shows in the right pane.
|
||||
await tryShot('cap5-doxa-maps-spa', '/index.html#/doxa-simple-map/doxa-simple-map/prayer',
|
||||
{ wait: 11000, waitFor: '#app' })
|
||||
|
||||
await browser.close(); server.close()
|
||||
console.log('fix done')
|
||||
121
internal/readme-shots.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* readme-shots.mjs — capture the README screenshot family from the BUILT tree.
|
||||
*
|
||||
* Serves public/ on a local port (same static server shape as smoke.mjs), then
|
||||
* drives headless chromium to screenshot each capability + deploy surface.
|
||||
* A live Mapbox token is fetched from https://doxa.life/api/maps/token and passed
|
||||
* as ?tk= so maps fully paint (network is up). Token-free surfaces (vision-card,
|
||||
* template) render regardless.
|
||||
*
|
||||
* Output: docs/readme-images/*.png
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile, stat, mkdir } from 'node:fs/promises'
|
||||
import { join, extname, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(HERE, '..', 'public')
|
||||
const OUT = resolve(HERE, '..', 'docs', 'readme-images')
|
||||
await mkdir(OUT, { recursive: true })
|
||||
|
||||
const TYPES = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript',
|
||||
'.json':'application/json', '.css':'text/css', '.webp':'image/webp', '.png':'image/png',
|
||||
'.svg':'image/svg+xml', '.geojson':'application/json', '.csv':'text/csv', '.woff2':'font/woff2' }
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
let p = join(ROOT, decodeURIComponent(req.url.split('?')[0]))
|
||||
if ((await stat(p)).isDirectory()) p = join(p, 'index.html')
|
||||
res.writeHead(200, { 'content-type': TYPES[extname(p)] || 'application/octet-stream' })
|
||||
res.end(await readFile(p))
|
||||
} catch { res.writeHead(404).end('not found') }
|
||||
})
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`
|
||||
|
||||
// Live token so maps paint fully.
|
||||
let TK = ''
|
||||
try {
|
||||
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
|
||||
TK = r.ok ? ((await r.json()).token || '') : ''
|
||||
} catch {}
|
||||
console.log(TK ? `token: acquired (${TK.length} chars) — maps will paint` : 'token: NONE — maps token-limited')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
|
||||
// path is relative to the tree root (public/). tk appended unless tokenFree.
|
||||
async function shot(name, path, {
|
||||
vp = { width: 1440, height: 900 }, wait = 6000, tokenFree = false,
|
||||
waitFor = null, before = null, fullPage = false, clip = null, dsf = 2,
|
||||
} = {}) {
|
||||
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: dsf })
|
||||
const page = await ctx.newPage()
|
||||
const url = BASE + path + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||||
if (waitFor) { try { await page.waitForSelector(waitFor, { timeout: 15000 }) } catch {} }
|
||||
if (before) await before(page)
|
||||
await page.waitForTimeout(wait)
|
||||
const opts = { path: join(OUT, name + '.png') }
|
||||
if (fullPage) opts.fullPage = true
|
||||
if (clip) opts.clip = clip
|
||||
await page.screenshot(opts)
|
||||
console.log(' OK', name + '.png', '<-', path)
|
||||
} catch (e) {
|
||||
console.log(' ERR', name, e.message.split('\n')[0])
|
||||
} finally { await ctx.close() }
|
||||
}
|
||||
|
||||
// mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas)
|
||||
const CANVAS = 'canvas'
|
||||
|
||||
// ---- CAP1: single element (token-free vision card) ----
|
||||
await shot('cap1-single-element', '/doxa-maps/doxa-vision-card/doxa-vision-card/index.html',
|
||||
{ tokenFree: true, wait: 3500 })
|
||||
|
||||
// ---- CAP2: prop-table -> 3 shots from ONE root.vue (prayer/engagement/adoption) ----
|
||||
await shot('cap2-prayer', '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html', { waitFor: CANVAS, wait: 8000 })
|
||||
await shot('cap2-engagement', '/doxa-maps/doxa-simple-map/doxa-simple-map/engagement/index.html', { waitFor: CANVAS, wait: 8000 })
|
||||
await shot('cap2-adoption', '/doxa-maps/doxa-simple-map/doxa-simple-map/adoption/index.html', { waitFor: CANVAS, wait: 8000 })
|
||||
|
||||
// ---- CAP3: monolithic research map ----
|
||||
await shot('cap3-monolithic-research', '/doxa-maps/doxa-research-map/research-map/index.html', { waitFor: CANVAS, wait: 9000 })
|
||||
|
||||
// ---- CAP4: nested-element SPA (upg-100-mfe is the cat-4 composite; list is the pages) ----
|
||||
await shot('cap4-upg100-mfe', '/doxa-maps/my-upg-100-list/upg-100-mfe/index.html', { wait: 8000 })
|
||||
await shot('cap4-upg100-list-page1', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', { wait: 7000 })
|
||||
// "page 2" — scroll the list down to show a later page of groups
|
||||
await shot('cap4-upg100-list-page2', '/doxa-maps/my-upg-100-list/upg-100-list/index.html', {
|
||||
wait: 7000,
|
||||
before: async (page) => {
|
||||
try { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) } catch {}
|
||||
// try clicking a "next" control if one exists
|
||||
for (const sel of ['button:has-text("Next")', '[aria-label="Next"]', '.pagination-next', 'button.next']) {
|
||||
try { const el = await page.$(sel); if (el) { await el.click(); break } } catch {}
|
||||
}
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- CAP5: nested SPA — the doxa-maps studio SPA (public/index.html) ----
|
||||
await shot('cap5-doxa-maps-spa', '/index.html', { wait: 8000, waitFor: '#app' })
|
||||
|
||||
// ---- DEPLOY1: iframe embed on a host page ----
|
||||
// Build a tiny host page that iframes a real map, serve it inline via data-ish approach:
|
||||
// simplest = a temp html written into public then screenshot. We instead write a host file.
|
||||
await shot('deploy1-iframe', '/__readme-iframe-host.html', { wait: 9000 })
|
||||
|
||||
// ---- DEPLOY2: mobile — the SPA at phone viewport ----
|
||||
await shot('deploy2-mobile-spa', '/index.html',
|
||||
{ vp: { width: 390, height: 844 }, wait: 8000, waitFor: '#app', dsf: 3 })
|
||||
|
||||
// ---- DEPLOY3: research dashboard ----
|
||||
await shot('deploy3-research-dashboard', '/doxa-maps/research-dashboard/research-dashboard/index.html',
|
||||
{ waitFor: CANVAS, wait: 10000 })
|
||||
|
||||
await browser.close()
|
||||
server.close()
|
||||
console.log('DONE — shots in docs/readme-images/')
|
||||
|
|
@ -333,12 +333,12 @@ onMounted(() => {
|
|||
if (mapId && e.detail?.mapId && e.detail.mapId !== mapId) return
|
||||
isCollapsed.value = false
|
||||
}
|
||||
window.addEventListener('doxa:openDesktopLegend', _onOpenDesktopLegend)
|
||||
window.addEventListener('dxm:openDesktopLegend', _onOpenDesktopLegend)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_onOpenDesktopLegend) {
|
||||
window.removeEventListener('doxa:openDesktopLegend', _onOpenDesktopLegend)
|
||||
window.removeEventListener('dxm:openDesktopLegend', _onOpenDesktopLegend)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
map (default map-control mode) or mounting it into this component's own
|
||||
container (`standalone` mode — search-only, no map required).
|
||||
• Supplementing Mapbox's forward-geocoder results with DOXA people-group,
|
||||
country, language and religion matches via `useDoxaSearch` (passed as
|
||||
country, language and religion matches via `useEntitySearch` (passed as
|
||||
the `localGeocoder` option — see Mapbox "custom-geocoder" example).
|
||||
Each DOXA row is rendered with a labeled category tag by the custom
|
||||
`render` callback (renderSuggestion).
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
<script setup>
|
||||
import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDoxaSearch } from '../../composables/useDoxaSearch.js'
|
||||
import { useEntitySearch } from '../../composables/useSearch.js'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
// The Mapbox and MapLibre geocoders emit identical DOM under different class
|
||||
|
|
@ -215,7 +215,7 @@ const props = defineProps({
|
|||
* Active search context — one of 'people-groups' | 'language' | 'regions' |
|
||||
* 'religion' (or '' for the default order). Mirrors the active legend tab so
|
||||
* the unified search surfaces the tab-relevant category first while still
|
||||
* matching every category (Round 16). Read live by useDoxaSearch on each
|
||||
* matching every category (Round 16). Read live by useEntitySearch on each
|
||||
* keystroke, so changing tabs needs no geocoder rebuild.
|
||||
*/
|
||||
activeContext: { type: String, default: '' }
|
||||
|
|
@ -352,7 +352,7 @@ function getActiveFilter() {
|
|||
return null
|
||||
}
|
||||
|
||||
const { search: doxaLocalGeocoder } = useDoxaSearch({
|
||||
const { search: doxaLocalGeocoder } = useEntitySearch({
|
||||
dataStore,
|
||||
dataSourceId: props.dataSourceId || undefined,
|
||||
getActiveFilter,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
* STYLES via useShadowStyles — a child component's <style> is dropped from the
|
||||
* custom element's shadow DOM.
|
||||
* OPT-IN via profile-config.qa (partner embeds off).
|
||||
*
|
||||
* PORTABILITY: this widget names NO specific host. The QA login is driven by
|
||||
* profile-config — a host that wants QA supplies its own `qaConnectUrl` (the
|
||||
* top-level connect page) and `qaProviderName` (the label shown on the button).
|
||||
* If `qaConnectUrl` is unset the login path simply doesn't render (opt-in anyway),
|
||||
* so dx-maps ships portable to any environment.
|
||||
*/
|
||||
import { ref, inject, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
|
@ -25,6 +31,14 @@ const mapLabel = computed(() =>
|
|||
profileConfig?.value?.qaLabel || profileConfig?.value?.profile || 'this map'
|
||||
)
|
||||
|
||||
// ── host-supplied QA identity provider (portable: dx-maps names none) ────────
|
||||
// `qaConnectUrl` = the top-level connect page the host serves (same-origin with
|
||||
// its auth API). `qaProviderName` = the label shown on the login button/notes.
|
||||
// Both empty by default → the login path does not render (opt-in only).
|
||||
const qaConnectUrl = computed(() => profileConfig?.value?.qaConnectUrl || '')
|
||||
const qaProviderName = computed(() => profileConfig?.value?.qaProviderName || 'your account')
|
||||
const canLogin = computed(() => !!qaConnectUrl.value)
|
||||
|
||||
const open = ref(false)
|
||||
function toggle() { open.value = !open.value }
|
||||
|
||||
|
|
@ -90,10 +104,11 @@ async function checkAuth() {
|
|||
}
|
||||
|
||||
function login() {
|
||||
// Top-level connect page (same-origin with the go-base API): checks the session,
|
||||
// posts the identity back here, then closes. Requires the sandbox to permit it.
|
||||
const w = window.open('/plugin-assets/qa-connect.html', 'nuxtinator-login',
|
||||
'width=480,height=640')
|
||||
// Top-level connect page (host-supplied, same-origin with its auth API): checks
|
||||
// the session, posts the identity back here, then closes. Requires the sandbox
|
||||
// to permit it. No-op if the host did not configure a connect URL.
|
||||
if (!qaConnectUrl.value) return
|
||||
const w = window.open(qaConnectUrl.value, 'qa-login', 'width=480,height=640')
|
||||
if (!w) authState.value = 'blocked'
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +172,7 @@ useShadowStyles(`
|
|||
{{ starting ? 'Starting…' : 'Start QA session' }}
|
||||
</button>
|
||||
<p v-if="startErr" class="qa-note" style="color:#e06a5a">⚠ {{ startErr }}</p>
|
||||
<p v-else class="qa-note">Signed in via Nuxtinator — opens a multiplayer QA room for this map.</p>
|
||||
<p v-else class="qa-note">Signed in via {{ qaProviderName }} — opens a multiplayer QA room for this map.</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="authState === 'checking'">
|
||||
|
|
@ -165,13 +180,13 @@ useShadowStyles(`
|
|||
</template>
|
||||
|
||||
<template v-else-if="authState === 'blocked'">
|
||||
<button class="qa-btn" type="button" @click="login">Log in with Nuxtinator</button>
|
||||
<button v-if="canLogin" class="qa-btn" type="button" @click="login">Log in with {{ qaProviderName }}</button>
|
||||
<p class="qa-note">This plugin is origin-isolated, so sign-in must open in a new window.</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button class="qa-btn" type="button" @click="login">Log in with Nuxtinator</button>
|
||||
<p class="qa-note">Sign in as a Nuxtinator user to start or join a session.</p>
|
||||
<button v-if="canLogin" class="qa-btn" type="button" @click="login">Log in with {{ qaProviderName }}</button>
|
||||
<p v-if="canLogin" class="qa-note">Sign in with {{ qaProviderName }} to start or join a session.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
219
library/components/search/FilterDropdown.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<script setup>
|
||||
/**
|
||||
* FilterDropdown.vue — SHARED (@map/components/search/FilterDropdown.vue).
|
||||
*
|
||||
* A searchable single-select dropdown with per-option counts. Fully props-driven,
|
||||
* emit-based, framework-agnostic (no Nuxt context, no i18n composables, no plugin
|
||||
* hardcoding) — so BOTH the cap-2 people-groups-grid AND the cap-1 Unified Research
|
||||
* Dashboard (big-picture) consume the SAME component instead of keeping local copies.
|
||||
*
|
||||
* Derived from DX-life (research-map-clean/app/components/public/FilterDropdown.vue),
|
||||
* Lit-derived Vue component. The SCSS the DX-life app kept in a shared _filters.scss
|
||||
* is inlined here (scoped to this element) so the dropdown styles itself when it ships
|
||||
* inside a standalone custom element / shadow root.
|
||||
*
|
||||
* ── API ────────────────────────────────────────────────────────────────────────
|
||||
* Props:
|
||||
* label String (required) — control label, e.g. "Region".
|
||||
* name String (required) — the axis/field key echoed back in emitted events.
|
||||
* options Array [] — [{ value, label, count?, type? }] to choose from.
|
||||
* value String '' — the currently-selected option value (controlled).
|
||||
* placeholder String 'Type to search...' — the in-menu search input placeholder.
|
||||
* Emits:
|
||||
* filter-change { name, value, label, type } — an option was picked.
|
||||
* filter-clear { name } — the selection was cleared.
|
||||
*/
|
||||
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
options: { type: Array, default: () => [] },
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: 'Type to search...' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['filter-change', 'filter-clear'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchText = ref('')
|
||||
const highlightedIndex = ref(-1)
|
||||
const rootRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchText.value) return props.options
|
||||
const search = searchText.value.toLowerCase()
|
||||
return props.options.filter(o => o.label.toLowerCase().includes(search))
|
||||
})
|
||||
|
||||
const selectedOption = computed(() => props.options.find(o => o.value === props.value))
|
||||
|
||||
function onClickOutside(e) {
|
||||
const path = e.composedPath?.() ?? []
|
||||
if (rootRef.value && !path.includes(rootRef.value)) close()
|
||||
}
|
||||
|
||||
function toggle() { isOpen.value ? close() : open() }
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
searchText.value = ''
|
||||
highlightedIndex.value = -1
|
||||
document.addEventListener('click', onClickOutside)
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
searchText.value = ''
|
||||
highlightedIndex.value = -1
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
}
|
||||
|
||||
function onSearchInput(e) {
|
||||
searchText.value = e.target.value
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
const options = filteredOptions.value
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, options.length - 1)
|
||||
scrollHighlightedIntoView()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
scrollHighlightedIntoView()
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const option = options[highlightedIndex.value]
|
||||
if (option) select(option)
|
||||
break
|
||||
}
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function scrollHighlightedIntoView() {
|
||||
nextTick(() => {
|
||||
const highlighted = rootRef.value?.querySelector('.filter-dropdown__option[data-highlighted]')
|
||||
highlighted?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
function select(option) {
|
||||
emit('filter-change', { name: props.name, value: option.value, label: option.label, type: option.type })
|
||||
close()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('filter-clear', { name: props.name })
|
||||
close()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="filter-dropdown" :class="{ 'is-open': isOpen }">
|
||||
<button
|
||||
class="filter-dropdown__trigger input"
|
||||
:data-active="value ? '' : undefined"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="filter-dropdown__label">
|
||||
<template v-if="selectedOption">{{ label }}: <strong>{{ selectedOption.label }}</strong></template>
|
||||
<template v-else>{{ label }}</template>
|
||||
</span>
|
||||
<svg class="filter-dropdown__chevron" width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<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="isOpen" class="filter-dropdown__menu" role="listbox">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
class="filter-dropdown__search"
|
||||
type="text"
|
||||
:value="searchText"
|
||||
:placeholder="placeholder"
|
||||
@input="onSearchInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button v-if="value" class="filter-dropdown__clear" type="button" @click="clear">Clear selection</button>
|
||||
<div class="filter-dropdown__options">
|
||||
<template v-if="filteredOptions.length === 0">
|
||||
<div class="filter-dropdown__no-options">No options found</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.value"
|
||||
class="filter-dropdown__option"
|
||||
role="option"
|
||||
:data-highlighted="index === highlightedIndex ? '' : undefined"
|
||||
:data-selected="option.value === value ? '' : undefined"
|
||||
:aria-selected="option.value === value"
|
||||
@click="select(option)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span class="filter-dropdown__count">{{ option.count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Inlined from DX-life blocks/_filters.scss (dropdown portion), CUBE custom-props
|
||||
resolved to concrete values so the element is self-styling in its shadow root. */
|
||||
.filter-dropdown { position: relative; min-width: 160px; max-width: 240px; flex: 1 1 160px; }
|
||||
@media (max-width: 576px) { .filter-dropdown { flex: 1 1 100%; max-width: 90vw; } }
|
||||
.filter-dropdown__trigger {
|
||||
width: 100%; padding: 6px 12px; cursor: pointer; display: flex; justify-content: space-between;
|
||||
align-items: center; gap: 6px; font-size: 0.875rem; color: inherit; text-align: start;
|
||||
background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; transition: border-color 150ms ease;
|
||||
}
|
||||
.filter-dropdown__trigger:hover { border-color: #2563eb; }
|
||||
.filter-dropdown__trigger[data-active] { background-color: rgba(37, 99, 235, 0.08); border-color: #2563eb; }
|
||||
.filter-dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.filter-dropdown__chevron { flex-shrink: 0; transition: transform 200ms ease; }
|
||||
.filter-dropdown.is-open .filter-dropdown__chevron { transform: rotate(180deg); }
|
||||
.filter-dropdown__menu {
|
||||
position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background-color: #fff;
|
||||
border: 1px solid #cbd5e1; border-radius: 8px; margin-top: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); overflow: hidden;
|
||||
}
|
||||
.filter-dropdown__search {
|
||||
border: none; border-bottom: 1px solid #e2e8f0; border-radius: 0; padding: 6px 12px; width: 100%;
|
||||
font-size: 0.875rem; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.filter-dropdown__search::placeholder { color: #94a3b8; }
|
||||
.filter-dropdown__clear {
|
||||
display: block; width: 100%; background: none; border: none; border-bottom: 1px solid #e2e8f0;
|
||||
padding: 6px 12px; font-size: 0.875rem; color: #2563eb; cursor: pointer; text-align: start;
|
||||
}
|
||||
.filter-dropdown__clear:hover { background-color: rgba(37, 99, 235, 0.08); }
|
||||
.filter-dropdown__options { max-height: 250px; overflow-y: auto; }
|
||||
.filter-dropdown__option {
|
||||
padding: 5px 12px; cursor: pointer; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 0.875rem; transition: background-color 100ms ease;
|
||||
}
|
||||
.filter-dropdown__option[data-selected] { font-weight: 600; color: #2563eb; }
|
||||
.filter-dropdown__option:hover,
|
||||
.filter-dropdown__option[data-highlighted] { background-color: rgba(37, 99, 235, 0.08); }
|
||||
.filter-dropdown__count { color: #64748b; font-size: 0.75rem; margin-inline-start: 6px; }
|
||||
.filter-dropdown__no-options { padding: 12px; font-size: 0.875rem; color: #94a3b8; text-align: center; }
|
||||
</style>
|
||||
99
library/components/search/README.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# `@map/components/search` — shared search + filter primitives
|
||||
|
||||
ONE source of truth for the search-bar, the searchable filter-dropdown, and the
|
||||
fuzzy matcher. Consumed by BOTH plugins so there is **no duplication**:
|
||||
|
||||
- **cap-2** `plugin-designs/people-groups-grid/` — its own header (default) drives the grid.
|
||||
- **cap-1** `plugin-designs/big-picture/` — the Unified Research Dashboard header
|
||||
(`BpResearchHeader.vue`) can adopt these to replace its hand-rolled `<input>` +
|
||||
native `<select>`s (card `BUILD-G1-CAT4-BIG-PICTURE-DASHBOARD` wires them in).
|
||||
|
||||
All three are **props-driven, emit-based, framework-agnostic** — no Nuxt context,
|
||||
no i18n composables, no plugin-specific hardcoding. Styles are inlined + scoped so
|
||||
each self-styles inside a standalone custom-element shadow root.
|
||||
|
||||
Import via the `@map` alias (resolves to `library/`):
|
||||
|
||||
```js
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `SearchBar.vue`
|
||||
|
||||
A magnifier-icon + `<input type="search">`.
|
||||
|
||||
| Prop | Type | Default | Purpose |
|
||||
|---------------|--------|------------|--------------------------------------------------------------------|
|
||||
| `modelValue` | String | `''` | Bound value; supports `v-model`. |
|
||||
| `placeholder` | String | `'Search…'`| Input placeholder text. |
|
||||
| `ariaLabel` | String | `'Search'` | Accessible label. |
|
||||
| `maxWidth` | String | `''` | Optional CSS max-width (e.g. `'480px'`); empty = fill container. |
|
||||
|
||||
| Event | Payload | When |
|
||||
|---------------------|-------------------|-------------------------------------------------------------|
|
||||
| `update:modelValue` | `String` | On every input (enables `v-model`). |
|
||||
| `search` | `{ value }` | On every input; `value` is the raw string (read in-control, so shadow-DOM retargeting never loses it). |
|
||||
|
||||
```vue
|
||||
<SearchBar :model-value="term" placeholder="Search people groups…" max-width="480px" @search="onSearch" />
|
||||
<!-- onSearch(payload) { term = payload.value } -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `FilterDropdown.vue`
|
||||
|
||||
A searchable single-select with per-option counts (the four axes:
|
||||
Region / Block / People-Group / Religion in the grid; Region / Status in the dashboard).
|
||||
|
||||
| Prop | Type | Required | Default | Purpose |
|
||||
|---------------|--------|----------|-----------------------|----------------------------------------------|
|
||||
| `label` | String | yes | — | Control label, e.g. `"Region"`. |
|
||||
| `name` | String | yes | — | Axis/field key echoed back in events. |
|
||||
| `options` | Array | no | `[]` | `[{ value, label, count?, type? }]`. |
|
||||
| `value` | String | no | `''` | Currently-selected value (controlled). |
|
||||
| `placeholder` | String | no | `'Type to search...'` | In-menu search placeholder. |
|
||||
|
||||
| Event | Payload | When |
|
||||
|-----------------|----------------------------------|----------------------------|
|
||||
| `filter-change` | `{ name, value, label, type }` | An option was picked. |
|
||||
| `filter-clear` | `{ name }` | Selection was cleared. |
|
||||
|
||||
```vue
|
||||
<FilterDropdown label="Region" name="wagf_region" :options="opts" :value="sel"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `fuzzy.js`
|
||||
|
||||
A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared
|
||||
`package.json`/`bun.lock`).
|
||||
|
||||
```js
|
||||
const fuse = new Fuse(list, {
|
||||
keys: ['name', 'country_code.label', 'religion.label'], // dotted paths
|
||||
threshold: 0, // 0 = exact substring only; >0 = fuzzy subsequence
|
||||
minMatchCharLength: 3, // shortest run counted as a match
|
||||
})
|
||||
fuse.search(term) // -> [{ item, matches: [{ key, indices: [[start,end], …] }] }]
|
||||
```
|
||||
|
||||
- Empty `term` → every item with `matches: []`.
|
||||
- `matches[].indices` drive `<span class="search-highlight">` wrapping (same shape as
|
||||
Fuse's `includeMatches`).
|
||||
|
||||
---
|
||||
|
||||
## Verification (headless)
|
||||
|
||||
- `fuzzy.js`: exact + fuzzy + empty-term contracts asserted via a node harness.
|
||||
- `SearchBar` / `FilterDropdown`: SSR-mounted (`@vue/server-renderer`) — render icon +
|
||||
input, trigger + label, and reflect a selected value.
|
||||
- `bun run build` builds the `people-groups-grid` bundle green consuming these shared
|
||||
imports (all guards pass).
|
||||
78
library/components/search/SearchBar.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script setup>
|
||||
/**
|
||||
* SearchBar.vue — SHARED (@map/components/search/SearchBar.vue).
|
||||
*
|
||||
* A reusable search input (magnifier icon + <input type="search">). Fully props-driven,
|
||||
* emit-based, framework-agnostic (no Nuxt, no i18n composables, no plugin hardcoding) —
|
||||
* so BOTH the cap-2 people-groups-grid AND the cap-1 Unified Research Dashboard
|
||||
* (big-picture) consume the SAME control instead of hand-rolling their own <input>.
|
||||
*
|
||||
* Derived from the search box that both plugins previously inlined (the DX-life
|
||||
* UupgsList search + BpResearchHeader search). The essential layout/look is inlined
|
||||
* and scoped so it self-styles inside a standalone custom element / shadow root.
|
||||
*
|
||||
* ── API ────────────────────────────────────────────────────────────────────────
|
||||
* Props:
|
||||
* modelValue String '' — bound value (supports `v-model` for controlled use).
|
||||
* placeholder String 'Search…' — the input placeholder text.
|
||||
* ariaLabel String 'Search' — accessible label for the input.
|
||||
* maxWidth String '' — optional CSS max-width for the box (e.g. '480px');
|
||||
* empty = fill the container (header-driven layouts).
|
||||
* Emits:
|
||||
* update:modelValue String — the new value (enables `v-model`).
|
||||
* search { value } — fired on every input; `value` is the raw string.
|
||||
* (Mirrors the grid's onSearch(event) contract, but the
|
||||
* value is read HERE so shadow-DOM event retargeting on the
|
||||
* async tick can never lose event.target — consumers get a
|
||||
* plain string, not an event.)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: 'Search…' },
|
||||
ariaLabel: { type: String, default: 'Search' },
|
||||
maxWidth: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const boxStyle = computed(() => (props.maxWidth ? { maxWidth: props.maxWidth } : {}))
|
||||
|
||||
function onInput(e) {
|
||||
const value = e.target.value ?? ''
|
||||
emit('update:modelValue', value)
|
||||
emit('search', { value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-box" :style="boxStyle">
|
||||
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="ariaLabel"
|
||||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Inlined + scoped so the control self-styles inside a shadow root. Values match the
|
||||
grid's original .search-box so the migrated people-groups-grid looks identical. */
|
||||
.search-box {
|
||||
position: relative; display: flex; align-items: center;
|
||||
margin: 0 auto; width: 100%; color: #64748b;
|
||||
}
|
||||
.search-box .search-icon { position: absolute; left: 12px; pointer-events: none; }
|
||||
.search-box input {
|
||||
width: 100%; padding: 10px 12px 10px 38px; font-size: 0.95rem; color: #0f172a;
|
||||
background: #fff; border: 1px solid #cbd5e1; border-radius: 10px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.search-box input:focus { border-color: #2563eb; }
|
||||
</style>
|
||||
105
library/components/search/fuzzy.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* fuzzy.js — SHARED (@map/components/search/fuzzy.js).
|
||||
*
|
||||
* A small, self-contained fuzzy matcher with the SAME public surface consumers
|
||||
* use from Fuse.js, vendored here so any doxa-maps element ships with NO external
|
||||
* npm dependency (fuse.js is not a dependency of the doxa-maps bundler, and adding
|
||||
* one would touch the shared package.json/bun.lock). This is the ONE shared home;
|
||||
* plugins (people-groups-grid, big-picture, …) import it via
|
||||
* import Fuse from '@map/components/search/fuzzy.js'
|
||||
* rather than keeping a local copy.
|
||||
*
|
||||
* COPY-ONLY NOTE: the DX-life source (UupgsList.vue) uses `new Fuse(list, opts).search(term)`
|
||||
* with { keys, threshold, includeMatches, ignoreLocation, minMatchCharLength }. We reproduce
|
||||
* exactly the parts of that contract the grid relies on:
|
||||
* - keys: array of dotted paths ('name', 'country_code.label', …) read from each item
|
||||
* - threshold: 0 = exact substring only; >0 = fuzzy (subsequence) allowed
|
||||
* - minMatchCharLength: shortest run counted as a match
|
||||
* - .search(term) -> [{ item, matches:[{ key, indices:[[start,end], …] }] }]
|
||||
* The `matches[].indices` drive the <span class="search-highlight"> wrapping in the grid,
|
||||
* identical in shape to Fuse's includeMatches output.
|
||||
*/
|
||||
|
||||
function getByPath(obj, path) {
|
||||
if (!path.includes('.')) return obj == null ? undefined : obj[path]
|
||||
const [head, ...rest] = path.split('.')
|
||||
let cur = obj == null ? undefined : obj[head]
|
||||
for (const k of rest) cur = cur == null ? undefined : cur[k]
|
||||
return cur
|
||||
}
|
||||
|
||||
// Contiguous case-insensitive substring match -> the run of [start,end] indices.
|
||||
function exactIndices(haystack, needle) {
|
||||
const h = haystack.toLowerCase()
|
||||
const n = needle.toLowerCase()
|
||||
const out = []
|
||||
let from = 0
|
||||
let idx = h.indexOf(n, from)
|
||||
while (idx !== -1) {
|
||||
out.push([idx, idx + n.length - 1])
|
||||
from = idx + n.length
|
||||
idx = h.indexOf(n, from)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Fuzzy (subsequence) match: every char of `needle` appears in order in `haystack`.
|
||||
// Returns the runs of consecutive matched positions, or null if not all chars matched.
|
||||
function fuzzyIndices(haystack, needle) {
|
||||
const h = haystack.toLowerCase()
|
||||
const n = needle.toLowerCase()
|
||||
const runs = []
|
||||
let hi = 0
|
||||
let runStart = -1
|
||||
let runEnd = -1
|
||||
for (let ni = 0; ni < n.length; ni++) {
|
||||
const ch = n[ni]
|
||||
let found = -1
|
||||
for (let k = hi; k < h.length; k++) {
|
||||
if (h[k] === ch) { found = k; break }
|
||||
}
|
||||
if (found === -1) return null
|
||||
if (runStart === -1) { runStart = runEnd = found }
|
||||
else if (found === runEnd + 1) { runEnd = found }
|
||||
else { runs.push([runStart, runEnd]); runStart = runEnd = found }
|
||||
hi = found + 1
|
||||
}
|
||||
if (runStart !== -1) runs.push([runStart, runEnd])
|
||||
return runs
|
||||
}
|
||||
|
||||
export default class Fuse {
|
||||
constructor(list, options = {}) {
|
||||
this.list = Array.isArray(list) ? list : []
|
||||
this.keys = options.keys || []
|
||||
this.threshold = typeof options.threshold === 'number' ? options.threshold : 0.4
|
||||
this.minMatchCharLength = options.minMatchCharLength || 1
|
||||
}
|
||||
|
||||
search(term) {
|
||||
const q = String(term || '').trim()
|
||||
if (!q) return this.list.map(item => ({ item, matches: [] }))
|
||||
if (q.length < this.minMatchCharLength) return []
|
||||
const exactOnly = this.threshold === 0
|
||||
const results = []
|
||||
for (const item of this.list) {
|
||||
const matches = []
|
||||
for (const key of this.keys) {
|
||||
const raw = getByPath(item, key)
|
||||
if (typeof raw !== 'string' || !raw) continue
|
||||
let indices = exactIndices(raw, q)
|
||||
if (indices.length === 0 && !exactOnly) {
|
||||
const fz = fuzzyIndices(raw, q)
|
||||
if (fz) {
|
||||
// keep only runs at least minMatchCharLength long (mirrors Fuse's minMatch)
|
||||
const kept = fz.filter(([s, e]) => e - s + 1 >= Math.min(this.minMatchCharLength, q.length))
|
||||
if (kept.length) indices = kept
|
||||
}
|
||||
}
|
||||
if (indices.length) matches.push({ key, indices })
|
||||
}
|
||||
if (matches.length) results.push({ item, matches })
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +288,7 @@ export function useGeocoderSearch(opts) {
|
|||
handled = true
|
||||
} else if (evt.kind === 'dialect') {
|
||||
// Build a legend-matching dialect key: "familyKey__baseLang__dialect".
|
||||
// useDoxaSearch threads familyDerived/baseLang/dialectLabel onto the Carmen
|
||||
// useEntitySearch threads familyDerived/baseLang/dialectLabel onto the Carmen
|
||||
// feature so we can construct the same key the legend's dialectRows compute.
|
||||
const labels = Array.isArray(evt.originalLabels) ? evt.originalLabels : []
|
||||
const family = evt.familyDerived || ''
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export function useMapEvents(options = {}) {
|
|||
// On desktop: if legend is collapsed, open it
|
||||
if (!uiStore.isMobile) {
|
||||
// Signal via custom event that the desktop legend can listen to
|
||||
window.dispatchEvent(new CustomEvent('doxa:openDesktopLegend', { detail: { mapId } }));
|
||||
window.dispatchEvent(new CustomEvent('dxm:openDesktopLegend', { detail: { mapId } }));
|
||||
}
|
||||
return; // Don't show popup — info is in the legend panel
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* useDoxaSearch.js — Local geocoder search composable
|
||||
* useSearch.js — Local geocoder search composable
|
||||
*
|
||||
* Supplements the Mapbox Geocoder (which only knows cities/countries)
|
||||
* with results from the local people-groups dataset. Returns matches
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* languages, and religions.
|
||||
*
|
||||
* Usage:
|
||||
* const { search } = useDoxaSearch({ dataStore, dataSourceId })
|
||||
* const { search } = useEntitySearch({ dataStore, dataSourceId })
|
||||
* // Pass `search` as `localGeocoder` option to MapboxGeocoder:
|
||||
* new MapboxGeocoder({ accessToken, localGeocoder: search })
|
||||
*
|
||||
|
|
@ -504,7 +504,7 @@ function makeAggregateFeature(kind, agg) {
|
|||
* @param {string} [opts.dataSourceId] Source id to read features from.
|
||||
* @returns {{ search: (query:string)=>Array, searchGrouped: (query:string)=>object }}
|
||||
*/
|
||||
export function useDoxaSearch(opts = {}) {
|
||||
export function useEntitySearch(opts = {}) {
|
||||
const { dataStore, dataSourceId, getActiveFilter, getActiveContext } = opts
|
||||
|
||||
// Called during GeocoderComponent setup → useI18n() resolves the per-map
|
||||
|
|
@ -675,4 +675,4 @@ export function useDoxaSearch(opts = {}) {
|
|||
return { search, searchGrouped, index, aggregates }
|
||||
}
|
||||
|
||||
export default useDoxaSearch
|
||||
export default useEntitySearch
|
||||
|
|
@ -17,21 +17,21 @@ const HIGHLIGHT_LAYER_ID = 'selected-pin-highlight';
|
|||
*/
|
||||
function createGoMarkerElement(color = GO_ORANGE) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "doxa-go-pin-wrapper";
|
||||
el.className = "map-go-pin-wrapper";
|
||||
const gradId = `pinGrad-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<style>
|
||||
/* Angled, brush-style pin: a slight tilt, a diagonal gradient and a stronger
|
||||
drop-shadow give it a hand-drawn, "set down on the map" look without raster art. */
|
||||
.doxa-go-pin-wrapper {
|
||||
.map-go-pin-wrapper {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.doxa-go-pin__svg {
|
||||
.map-go-pin__svg {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 40px;
|
||||
|
|
@ -40,7 +40,7 @@ function createGoMarkerElement(color = GO_ORANGE) {
|
|||
</style>
|
||||
|
||||
<svg
|
||||
class="doxa-go-pin__svg"
|
||||
class="map-go-pin__svg"
|
||||
viewBox="-2 -2 32 40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Selected people group"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
// Composables — populated during migration-001+
|
||||
// export { useMapData } from './composables/useMapData.js'
|
||||
// export { useMapLayers } from './composables/useMapLayers.js'
|
||||
// export { useDoxaSearch } from './composables/useDoxaSearch.js'
|
||||
// export { useEntitySearch } from './composables/useSearch.js'
|
||||
|
||||
// Colors — the old monolith shims (colorStrategies.js / colors.js /
|
||||
// prayerColors.js) are retired to ../_archive/ (see its README). Import the
|
||||
|
|
|
|||
468
library/layouts/VerticalTabsLayout.vue
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
<!--
|
||||
VerticalTabsLayout.vue — a shared, brand-neutral vertical-tabs shell layout.
|
||||
|
||||
★ WHAT IT IS. A reusable app-shell layout: a top bar, a sectioned/foldered vertical rail, and a
|
||||
content frame. Brand-neutral by design — it bakes in NO product name, so any app can reuse it.
|
||||
Consumers inject their own branding via the `title` prop and the #brand / #meta / #toolbar slots.
|
||||
A real compiled Vue component with self-contained CSS and NO UI-framework dependency (no MUI /
|
||||
Vuetify / CDN) — it works out of the box and offline.
|
||||
|
||||
★ SHADOW-DOM SAFETY. When a consumer bundle compiles with vue({customElement:true}) and renders
|
||||
inside a shadow root, plugin-vue attaches each SFC's <style> to that component's own styles[] —
|
||||
but Vue only injects the ROOT custom element's styles[] into the shadow root; a CHILD component's
|
||||
styles[] are silently dropped (see composables/useShadowStyles.js). This layout is typically
|
||||
imported as a CHILD of a consumer's root SFC, so its <style> block alone would NOT reach the
|
||||
shadow root. It therefore injects its critical CSS via useShadowStyles() at setup() — correct in
|
||||
both light-DOM and shadow-DOM builds, idempotent-by-key (a double inject is harmless). The <style>
|
||||
block below is the same CSS for dev / light-DOM readability; keep the two in sync.
|
||||
|
||||
★ TWO SHAPES, ONE COMPONENT:
|
||||
- FLAT list: pass :items="[{key,label,sub,emoji,icon,...}]"
|
||||
- GROUPED tiers: pass :groups="[{key,label,badge,count,icon,folder,rows}]"
|
||||
A row/group may carry EITHER an `emoji` (a unicode glyph) OR an `icon` (a LOCAL icon name resolved
|
||||
to inline SVG — no CDN, offline safe). `icon` wins if both are set. A GROUP with `folder:true`
|
||||
(and optional `open:false`) renders a COLLAPSIBLE section (click the heading — chevron rotates); a
|
||||
plain group is an always-open labelled section (the "Subscriptions" style).
|
||||
Selection is controlled via v-model:active (the active item's `key`). The host owns pane content
|
||||
and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and
|
||||
exposes slots so each app keeps its unique chrome + its own mount machinery:
|
||||
#brand — left of the title (optional logo / icon; the consumer's branding lives here)
|
||||
#meta — right side of the top bar (e.g. a count, a language switcher)
|
||||
#toolbar — a full-width strip under the top bar, above the content (e.g. an action bar)
|
||||
#default — the content pane(s); the host renders its own panes and toggles them on `active`
|
||||
|
||||
★ NESTED-ELEMENT HOSTING (opt-in, LAZY). A row/item may instead ask THIS layout to host a nested
|
||||
custom element for its tab — so a shell can compose other element-plugins as its tabs. Add to a
|
||||
row/item:
|
||||
element : a custom-element tag name to render for this tab, e.g. "people-groups-grid"
|
||||
src? : a bundle URL to inject (once, deduped) if `element` isn't registered yet
|
||||
attrs? : a map of attributes/props to set on the element BEFORE it mounts ("props in advance")
|
||||
When present, the layout mounts THAT element into the content frame for that tab, so the host needs
|
||||
no #default pane machinery. The #default slot still works for hosts that render their own panes —
|
||||
`element` is an ALTERNATIVE, not a replacement (a host can mix: element-rows get hosted panes, the
|
||||
rest fall through to #default).
|
||||
|
||||
▸ LAZY: a tab's element is mounted ONLY on FIRST activation (never eagerly). We track the set of
|
||||
activated keys; the pane for a key renders/mounts the first time that key becomes `active`, and
|
||||
after that it is KEPT ALIVE (v-show toggle) so re-selecting is instant and its state survives —
|
||||
but a tab the user never opens costs nothing (no bundle fetch, no element upgrade). If a row has
|
||||
`src`, the bundle is injected lazily (once per src) at first activation, and the element is
|
||||
created only after customElements.get(tag) resolves (injectScriptOnce + waitForDefine).
|
||||
|
||||
★ DEFAULT DARK THEME. The inlined token values are just colours: a neutral-900 canvas, a SOLID
|
||||
accented active row (not a coloured gradient), subtle borders, and green-400 (#00DC82) as the one
|
||||
accent. They ship as literals (a shadow-DOM custom element cannot import an app's design tokens —
|
||||
that is the boundary), so the rail looks finished out of the box with zero runtime dependency. A
|
||||
consumer can override the look via the CSS custom properties (--vtl-accent, --vtl-border, etc.).
|
||||
|
||||
The rail is keyboard-accessible (role="tablist", ArrowUp/Down + Home/End move focus & select), the
|
||||
active row is highlighted, and the whole thing is responsive (rail collapses to an off-canvas drawer
|
||||
under 760px, toggled by the ☰ button).
|
||||
-->
|
||||
<template>
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen }">
|
||||
<header class="vtl-top">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
aria-label="Toggle list">☰</button>
|
||||
<span class="vtl-brand"><slot name="brand" /></span>
|
||||
<b class="vtl-title">{{ title }}</b>
|
||||
<span class="vtl-meta"><slot name="meta" /></span>
|
||||
</header>
|
||||
|
||||
<div class="vtl-body">
|
||||
<nav class="vtl-nav" :aria-label="navLabel" v-show="navOpen" role="tablist" aria-orientation="vertical">
|
||||
<!-- GROUPED shape: labelled sections (always-open) or collapsible folders. -->
|
||||
<template v-if="groups && groups.length">
|
||||
<div class="vtl-grp" v-for="grp in groups" :key="grp.key">
|
||||
<!-- FOLDER heading = a button that toggles open/closed (chevron rotates). -->
|
||||
<button
|
||||
v-if="grp.folder" type="button" class="vtl-tier vtl-tier-btn"
|
||||
:aria-expanded="String(!isFolderClosed(grp))"
|
||||
@click="toggleFolder(grp.key)">
|
||||
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
|
||||
<span class="vtl-tl">{{ grp.label }}</span>
|
||||
<span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
|
||||
</button>
|
||||
<!-- SECTION heading = a static always-visible label (with optional badge/count). -->
|
||||
<div v-else class="vtl-tier">
|
||||
<span class="vtl-tier-ic" v-if="grp.icon" aria-hidden="true" v-html="iconSvg(grp.icon)"></span>
|
||||
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
|
||||
<span class="vtl-tl">{{ grp.label }}</span>
|
||||
<span class="vtl-td" v-if="grp.count != null">{{ grp.count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="vtl-rows" v-show="!grp.folder || !isFolderClosed(grp)">
|
||||
<button
|
||||
v-for="row in grp.rows" :key="row.key" type="button" role="tab"
|
||||
class="vtl-tab" :class="{ active: row.key === active }"
|
||||
:aria-selected="String(row.key === active)"
|
||||
:tabindex="row.key === active ? 0 : -1"
|
||||
:ref="el => registerTab(row.key, el)"
|
||||
@click="pick(row.key)" @keydown="onKey($event)">
|
||||
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
||||
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
||||
<span class="vtl-tab-text">
|
||||
<span class="vtl-tab-label">{{ row.label }}<span class="vtl-tab-badge" v-if="row.badge">{{ row.badge }}</span></span>
|
||||
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FLAT shape: a single ungrouped list -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="row in items" :key="row.key" type="button" role="tab"
|
||||
class="vtl-tab" :class="{ active: row.key === active }"
|
||||
:aria-selected="String(row.key === active)"
|
||||
:tabindex="row.key === active ? 0 : -1"
|
||||
:ref="el => registerTab(row.key, el)"
|
||||
@click="pick(row.key)" @keydown="onKey($event)">
|
||||
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
||||
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
||||
<span class="vtl-tab-text">
|
||||
<span class="vtl-tab-label">{{ row.label }}</span>
|
||||
<span class="vtl-tab-sub" v-if="row.sub">{{ row.sub }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<main class="vtl-main">
|
||||
<slot name="toolbar" />
|
||||
<div class="vtl-content">
|
||||
<!-- Lazily-hosted nested elements: one pane per element-bearing row, rendered only after its
|
||||
tab has been activated once (v-if on activatedKeys), then kept alive (v-show) so its
|
||||
state survives tab switches. Each pane is an empty Vue-unmanaged div we mount the custom
|
||||
element into imperatively — Vue never manages the element's own shadow DOM. -->
|
||||
<section
|
||||
v-for="row in mountedElementRows" :key="row.key"
|
||||
class="vtl-host" v-show="row.key === active" :hidden="row.key !== active">
|
||||
<div class="vtl-host-slot" :ref="el => setHostSlot(row.key, el)"></div>
|
||||
<div v-if="hostState[row.key] === 'loading'" class="vtl-host-hint">Loading {{ row.label }}…</div>
|
||||
<div v-else-if="hostState[row.key] === 'error'" class="vtl-host-hint err">
|
||||
Could not load {{ row.label }}.<br><small>{{ hostError[row.key] }}</small>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Host-owned panes (the SPA/studio render their own here). Always available. -->
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { icon as localIcon } from '@map/layouts/app-wrapper-material/icons.js'
|
||||
|
||||
// Inject a bundle script exactly once per URL (deduped across all layout instances). Resolves after
|
||||
// the IIFE has executed (so its customElements.define has run). Mirrors the SPA/studio's once().
|
||||
const _vtlLoaded = {}
|
||||
function injectScriptOnce(url) {
|
||||
if (!url) return Promise.resolve()
|
||||
if (_vtlLoaded[url]) return _vtlLoaded[url]
|
||||
_vtlLoaded[url] = new Promise((ok, fail) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = url
|
||||
s.onload = () => ok()
|
||||
s.onerror = () => { delete _vtlLoaded[url]; fail(new Error('failed to load ' + url)) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return _vtlLoaded[url]
|
||||
}
|
||||
|
||||
// Wait until a custom element tag is defined (up to ~5s). If a src bundle was injected, its
|
||||
// define() has already run by onload; this covers elements registered async after script exec.
|
||||
function waitForDefine(tag, timeoutMs = 5000) {
|
||||
if (typeof customElements === 'undefined') return Promise.resolve()
|
||||
if (customElements.get(tag)) return Promise.resolve()
|
||||
if (customElements.whenDefined) {
|
||||
return Promise.race([
|
||||
customElements.whenDefined(tag),
|
||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout waiting for <' + tag + '>')), timeoutMs))
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Injected into the shadow root at mount (see the header comment for the full why). Kept free of
|
||||
// block comments and ${} substitutions so the build-time strip-shadow-css plugin ships it verbatim.
|
||||
const VTL_SHADOW_CSS = `
|
||||
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#404040; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
|
||||
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
|
||||
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
.vtl-tier-btn { border:0; background:none; color:inherit; font:inherit; cursor:pointer; text-align:left; border-radius:8px; }
|
||||
.vtl-tier-btn:hover { background:var(--vtl-accented); }
|
||||
.vtl-tier-btn:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tier-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tier-ic svg { width:15px; height:15px; }
|
||||
.vtl-cap { font:700 10px/1 system-ui; letter-spacing:.4px; color:#052e16; background:var(--vtl-accent); border-radius:999px; padding:3px 7px; }
|
||||
.vtl-tl { font-weight:600; font-size:11px; letter-spacing:.03em; text-transform:uppercase; color:var(--vtl-muted); }
|
||||
.vtl-td { color:#6b6b6b; font-size:11px; margin-left:auto; }
|
||||
.vtl-chev { margin-left:auto; display:inline-flex; color:var(--vtl-muted); transition:transform .16s ease; }
|
||||
.vtl-chev svg { width:15px; height:15px; }
|
||||
.vtl-chev.open { transform:rotate(90deg); }
|
||||
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
|
||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
|
||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
|
||||
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
|
||||
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tab-ic svg { width:20px; height:20px; }
|
||||
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
|
||||
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
|
||||
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
|
||||
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
|
||||
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:#64748b; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl-menu { display:inline-block; }
|
||||
.vtl-meta { display:none; }
|
||||
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
|
||||
.vtl:not(.nav-open) .vtl-nav { display:none; }
|
||||
}
|
||||
`
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VerticalTabsLayout',
|
||||
props: {
|
||||
// Controlled active key (v-model:active). Matches an item's / row's `key`.
|
||||
active: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
// FLAT shape: [{ key, label, sub?, emoji? }]
|
||||
items: { type: Array, default: () => [] },
|
||||
// GROUPED shape: [{ key, label, badge?, count?, rows: [{ key, label, sub?, emoji? }] }]
|
||||
// When `groups` is non-empty it takes precedence over `items`.
|
||||
groups: { type: Array, default: () => [] },
|
||||
navLabel: { type: String, default: 'Sections' },
|
||||
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
|
||||
initialNavOpen: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['update:active'],
|
||||
setup() {
|
||||
useShadowStyles(VTL_SHADOW_CSS, 'vertical-tabs-layout')
|
||||
},
|
||||
data() {
|
||||
// _folderOverride: user toggles keyed by group key (true=open, false=closed). When a folder
|
||||
// key is absent here we fall back to the group's own `open` flag (default closed) — so a
|
||||
// folder authored with open:false starts collapsed and the user can still open it.
|
||||
//
|
||||
// Nested-element hosting (lazy): activatedKeys marks which element-rows have EVER been the active
|
||||
// tab (so their pane is rendered + element mounted); hostState/hostError track each pane's load.
|
||||
// A row not in activatedKeys is never rendered — the lazy contract (no visit → no cost).
|
||||
return {
|
||||
navOpen: this.initialNavOpen, _tabEls: {}, folderOverride: {},
|
||||
activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Every row/item flattened across whichever shape is in use (groups take precedence over items).
|
||||
allRows() {
|
||||
if (this.groups && this.groups.length) {
|
||||
const out = []
|
||||
for (const g of this.groups) for (const r of (g.rows || [])) out.push(r)
|
||||
return out
|
||||
}
|
||||
return this.items
|
||||
},
|
||||
// Flat ordered list of keys — drives keyboard nav.
|
||||
orderedKeys() { return this.allRows.map(r => r.key) },
|
||||
// Rows that ask THIS layout to host a nested custom element (have an `element` tag).
|
||||
elementRows() { return this.allRows.filter(r => r && r.element) },
|
||||
// Element rows that have been activated at least once — only these get a rendered/mounted pane
|
||||
// (the lazy contract: a never-visited tab is never in this list, so it costs nothing).
|
||||
mountedElementRows() { return this.elementRows.filter(r => this.activatedKeys[r.key]) }
|
||||
},
|
||||
watch: {
|
||||
// Lazily activate an element-hosting tab the FIRST time it becomes active (mount on demand,
|
||||
// then keep alive). Non-element tabs are ignored here (the host renders those in #default).
|
||||
active: {
|
||||
immediate: true,
|
||||
handler(key) { this.activateElement(key) }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
// The immediate `active` watcher may have run before the pane div existed; re-run for the current
|
||||
// active key now that the DOM is up, so an element-tab active at first render mounts.
|
||||
this.$nextTick(() => this.activateElement(this.active))
|
||||
},
|
||||
methods: {
|
||||
registerTab(key, el) { if (el) this._tabEls[key] = el },
|
||||
setHostSlot(key, el) { if (el) { this._hostSlots[key] = el; this.$nextTick(() => this.mountHostElement(key)) } },
|
||||
// Mark an element-row activated (renders its pane) on first activation; mount happens once the
|
||||
// pane's slot div exists (setHostSlot → mountHostElement). Idempotent: re-activating is a no-op.
|
||||
activateElement(key) {
|
||||
if (!key) return
|
||||
const row = this.elementRows.find(r => r.key === key)
|
||||
if (!row) return // not an element-hosting tab
|
||||
if (!this.activatedKeys[key]) {
|
||||
this.activatedKeys = { ...this.activatedKeys, [key]: true }
|
||||
this.hostState = { ...this.hostState, [key]: 'loading' }
|
||||
}
|
||||
// If the pane/slot already exists (re-activation, or pane rendered), ensure it is mounted.
|
||||
this.$nextTick(() => this.mountHostElement(key))
|
||||
},
|
||||
// Imperatively mount the row's custom element into its empty slot div — lazy-inject its bundle
|
||||
// first (deduped), wait for the tag to be defined, set `attrs` BEFORE append (props-in-advance),
|
||||
// then append. Runs once per key (guarded by _hostMounted).
|
||||
mountHostElement(key) {
|
||||
if (this._hostMounted[key]) return
|
||||
const row = this.elementRows.find(r => r.key === key)
|
||||
const slot = this._hostSlots[key]
|
||||
if (!row || !slot) return // pane not in the DOM yet; a later tick will retry
|
||||
this._hostMounted[key] = true
|
||||
this.hostState = { ...this.hostState, [key]: 'loading' }
|
||||
injectScriptOnce(row.src)
|
||||
.then(() => waitForDefine(row.element))
|
||||
.then(() => {
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(row.element)
|
||||
// props-in-advance: set each attr as BOTH a property (if upgraded) and an attribute, so the
|
||||
// element sees them at its first connectedCallback whether it reads props or attributes.
|
||||
const attrs = row.attrs || {}
|
||||
for (const k in attrs) {
|
||||
const v = attrs[k]
|
||||
const val = (v != null && typeof v === 'object') ? JSON.stringify(v) : String(v)
|
||||
try { el[k] = v } catch { /* not a settable prop; attribute below covers it */ }
|
||||
try { el.setAttribute(k, val) } catch { /* ignore invalid attr name */ }
|
||||
}
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
slot.appendChild(el)
|
||||
this.hostState = { ...this.hostState, [key]: 'ready' }
|
||||
})
|
||||
.catch((e) => {
|
||||
this._hostMounted[key] = false // allow a retry on next activation
|
||||
this.hostState = { ...this.hostState, [key]: 'error' }
|
||||
this.hostError = { ...this.hostError, [key]: (e && e.message) || String(e) }
|
||||
})
|
||||
},
|
||||
// Resolve a LOCAL icon name to its inline SVG string (app-wrapper-material/icons.js). Names come
|
||||
// from OUR nav model (never user input); unknown names fall back to the library's own default.
|
||||
iconSvg(name) { return name ? localIcon(name) : '' },
|
||||
// A folder is closed unless the user toggled it open, or (no toggle yet) unless it was authored
|
||||
// with open:true. `grp.open === true` opens by default; anything else starts collapsed.
|
||||
isFolderClosed(grp) {
|
||||
const ov = this.folderOverride[grp.key]
|
||||
if (ov != null) return !ov
|
||||
return grp.open !== true
|
||||
},
|
||||
toggleFolder(key) {
|
||||
const cur = this.folderOverride[key]
|
||||
const grp = (this.groups || []).find(g => g.key === key)
|
||||
const wasOpen = cur != null ? cur : (grp && grp.open === true)
|
||||
this.folderOverride = { ...this.folderOverride, [key]: !wasOpen }
|
||||
},
|
||||
pick(key) {
|
||||
this.$emit('update:active', key)
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
// ArrowUp/Down cycle the rail; Home/End jump to the ends. Moves selection AND focus so a
|
||||
// keyboard user drives the shell exactly like a native tablist.
|
||||
onKey(e) {
|
||||
const keys = this.orderedKeys
|
||||
if (!keys.length) return
|
||||
const cur = keys.indexOf(this.active)
|
||||
let next = -1
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (cur + 1) % keys.length
|
||||
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (cur - 1 + keys.length) % keys.length
|
||||
else if (e.key === 'Home') next = 0
|
||||
else if (e.key === 'End') next = keys.length - 1
|
||||
else return
|
||||
e.preventDefault()
|
||||
const key = keys[next]
|
||||
this.pick(key)
|
||||
this.$nextTick(() => { const el = this._tabEls[key]; if (el && el.focus) el.focus() })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Mirror of VTL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
|
||||
Default dark theme (values are just colours, overridable via the --vtl-* custom properties):
|
||||
neutral-900 canvas, a SOLID accented active row (not a gradient), subtle borders, green-400
|
||||
(#00DC82) accent, inline SVG icons. */
|
||||
.vtl { position:absolute; inset:0; display:flex; flex-direction:column; height:100%; width:100%; min-height:420px; background:#171717; color:#f5f5f5; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --vtl-accent:#00DC82; --vtl-border:#262626; --vtl-elevated:#1f1f1f; --vtl-accented:#404040; --vtl-muted:#a3a3a3; }
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--vtl-border); background:#1f1f1f; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#f5f5f5; background:#262626; border:1px solid #404040; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
.vtl-menu:hover { background:#404040; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
.vtl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#f5f5f5; }
|
||||
.vtl-meta { color:var(--vtl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
|
||||
.vtl-body { flex:1 1 auto; display:flex; min-height:0; }
|
||||
.vtl-nav { flex:0 0 256px; overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--vtl-border); padding:12px 12px 20px; background:#1f1f1f; scrollbar-width:thin; scrollbar-color:#404040 transparent; }
|
||||
.vtl-nav::-webkit-scrollbar { width:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#404040; border-radius:8px; }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
.vtl-tier-btn { border:0; background:none; color:inherit; font:inherit; cursor:pointer; text-align:left; border-radius:8px; }
|
||||
.vtl-tier-btn:hover { background:var(--vtl-accented); }
|
||||
.vtl-tier-btn:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tier-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tier-ic svg { width:15px; height:15px; }
|
||||
.vtl-cap { font:700 10px/1 system-ui; letter-spacing:.4px; color:#052e16; background:var(--vtl-accent); border-radius:999px; padding:3px 7px; }
|
||||
.vtl-tl { font-weight:600; font-size:11px; letter-spacing:.03em; text-transform:uppercase; color:var(--vtl-muted); }
|
||||
.vtl-td { color:#6b6b6b; font-size:11px; margin-left:auto; }
|
||||
.vtl-chev { margin-left:auto; display:inline-flex; color:var(--vtl-muted); transition:transform .16s ease; }
|
||||
.vtl-chev svg { width:15px; height:15px; }
|
||||
.vtl-chev.open { transform:rotate(90deg); }
|
||||
.vtl-rows { display:flex; flex-direction:column; gap:1px; }
|
||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 10px; cursor:pointer; border-radius:8px; font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||
.vtl-tab:hover { background:var(--vtl-accented); color:#f5f5f5; }
|
||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#f5f5f5; font-weight:500; }
|
||||
.vtl-tab.active .vtl-tab-ic { color:var(--vtl-accent); }
|
||||
.vtl-tab-ic { flex:0 0 auto; display:inline-flex; color:var(--vtl-muted); }
|
||||
.vtl-tab-ic svg { width:20px; height:20px; }
|
||||
.vtl-tab-emoji { font-size:17px; line-height:1; flex:0 0 auto; }
|
||||
.vtl-tab-text { display:flex; flex-direction:column; min-width:0; }
|
||||
.vtl-tab-label { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:6px; }
|
||||
.vtl-tab-badge { font:600 9px/1 system-ui; letter-spacing:.3px; text-transform:uppercase; color:var(--vtl-accent); border:1px solid var(--vtl-accent); border-radius:999px; padding:2px 5px; }
|
||||
.vtl-tab-sub { font-size:11px; color:#6b6b6b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.vtl-tab-sub code { font-family:ui-monospace,Menlo,monospace; }
|
||||
.vtl-tab.active .vtl-tab-sub { color:var(--vtl-muted); }
|
||||
.vtl-main { flex:1 1 auto; position:relative; min-width:0; display:flex; flex-direction:column; }
|
||||
.vtl-content { flex:1 1 auto; position:relative; min-height:0; }
|
||||
.vtl-host { position:absolute; inset:0; }
|
||||
.vtl-host-slot { position:absolute; inset:0; }
|
||||
.vtl-host-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#94a3b8; font-size:13px; padding:20px; }
|
||||
.vtl-host-hint.err { color:#f87171; }
|
||||
.vtl-host-hint small { color:#64748b; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl-menu { display:inline-block; }
|
||||
.vtl-meta { display:none; }
|
||||
.vtl-nav { position:absolute; z-index:20; top:0; bottom:0; left:0; box-shadow:0 0 28px rgba(0,0,0,.55); }
|
||||
.vtl:not(.nav-open) .vtl-nav { display:none; }
|
||||
}
|
||||
</style>
|
||||
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
11
package.json
|
|
@ -3,11 +3,11 @@
|
|||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"description": "A portable bundler that generates embeddable JAMstack maps — one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.",
|
||||
"description": "A portable bundler that generates embeddable JAMstack maps \u2014 one library, many map bundles that embed on any site, compose into a lazy-loaded SPA, or wrap a Capacitor Android/iOS app.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun x vite",
|
||||
"build": "bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:drop && bun run check:parse && bun run verify && bun run gallery",
|
||||
"build": "bash internal/build-lock.sh",
|
||||
"build:watch": "bun x vite build --watch",
|
||||
"preview": "bun x vite preview",
|
||||
"clean": "node internal/clean.mjs",
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"verify": "node library/scripts/research/verify-all.mjs",
|
||||
"verify:full": "node library/scripts/research/verify-all.mjs --full",
|
||||
"serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public",
|
||||
"serve:shell": "python3 -m http.server 4174 --bind 0.0.0.0 --directory layouts/app-wrapper-material",
|
||||
"serve:shell": "python3 -m http.server 4174 --bind 0.0.0.0 --directory library/layouts/app-wrapper-material",
|
||||
"cap:sync": "bunx cap sync android",
|
||||
"cap:verify": "test -f capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
|
||||
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd capacitor/android && ./gradlew assembleDebug",
|
||||
|
|
@ -41,7 +41,8 @@
|
|||
"probe:legend-columns": "node internal/legend-columns-probe.mjs",
|
||||
"sync:dropin": "node internal/sync-dropin.mjs",
|
||||
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune # destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
|
||||
"check:public-hygiene": "node internal/public-hygiene-check.mjs"
|
||||
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
|
||||
"build:unlocked": "bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:drop && bun run check:parse && bun run verify && bun run gallery"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "7",
|
||||
|
|
@ -66,4 +67,4 @@
|
|||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
plugin-designs/big-picture/big-picture.instances.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the composite <bp-big-picture>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so the element mounts INSTANTLY with no Mapbox token ladder — correct for this self-contained, mapless Cat-4 dashboard. generate-tree stamps the bundle's HEADLINE tag (bp-big-picture) on every instance page, so all preset instances render the COMPOSITE. The INNER elements (bp-research-header, bp-groups-table) are individually embeddable via their own registered tags + SRI script (see manifest.elements and the staging index.html section 2, which wires them standalone through the DOM).",
|
||||
"instances": [
|
||||
{
|
||||
"id": "big-picture",
|
||||
"title": "Big Picture — research header ↔ people-groups grid (composite)",
|
||||
"props": { "profile": "big-picture", "provider": "maplibre", "instanceId": "bp-big-picture" }
|
||||
},
|
||||
{
|
||||
"id": "frontier",
|
||||
"title": "Big Picture — frontier accent",
|
||||
"props": {
|
||||
"profile": "big-picture",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "bp-big-picture-frontier",
|
||||
"accent": "#22d3ee",
|
||||
"title": "Big Picture — Frontier Focus"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
plugin-designs/big-picture/big-picture.meta.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'nested-elements-mfe' (display alias 'composite-element') = Capability-4: this plugin's element NESTS other custom elements from the same bundle (<bp-research-header> Cat-1 + the REAL cap-2 <people-groups-grid>) and wires them via props/events (header SHARED SearchBar + 4 FilterDropdowns → grid filter). This explicit meta is the ONLY way a composite tier is declared (discover.mjs never infers it from source). Filename must be <profile>.meta.json where profile = the root .vue name (big-picture).",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["bp-research-header", "people-groups-grid"],
|
||||
"label": "Big Picture (research header ↔ real people-groups grid via props/events)"
|
||||
}
|
||||
128
plugin-designs/big-picture/big-picture.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script setup>
|
||||
/**
|
||||
* big-picture.vue — Plugin: "Big Picture" (Capability-4 nested-elements-mfe).
|
||||
*
|
||||
* @description The Cat-4 composite that PAIRS the two real capability elements of the
|
||||
* research page via **props + events** (the deploy-method-3 contract — no shared
|
||||
* JS bus, no Pinia store; the parent is the only wire):
|
||||
*
|
||||
* TOP = <BpResearchHeader> (Cat-1) — the Unified Research Dashboard header.
|
||||
* Its hand-rolled search/select controls are GONE; it now uses the
|
||||
* SHARED <SearchBar> + FOUR shared <FilterDropdown>s (Region / Block /
|
||||
* People-Group / Religion). Emits `research-filter`
|
||||
* { query, wagfRegion, wagfBlock, rop1, religion } on change.
|
||||
* BELOW= the REAL cap-2 <PeopleGroupsGrid> (plugin-designs/people-groups-grid),
|
||||
* the live pray.doxa.life people-groups grid, with its OWN controls
|
||||
* TURNED OFF (:show-search / :show-filters = false) so the header
|
||||
* drives it purely through props.
|
||||
*
|
||||
* WIRING (the load-bearing proof):
|
||||
* 1. The header emits `research-filter` (@research-filter) → this parent stores
|
||||
* { query, wagfRegion, wagfBlock, rop1, religion } in reactive state.
|
||||
* 2. This parent passes that state DOWN to the grid as
|
||||
* :query / :wagf-region / :wagf-block / :rop1 / :religion props → the real
|
||||
* grid re-filters against its live data. (Header control → grid filter.)
|
||||
* 3. The grid emits `groups-filtered` { total, … } (@groups-filtered) → this
|
||||
* parent stores the total and passes it back UP into the header's :count prop.
|
||||
* => Searching / picking a dropdown in the dashboard header filters the REAL
|
||||
* people-groups grid below it, and the header shows the live matched count.
|
||||
* Data down, events up.
|
||||
*
|
||||
* Both inner elements stay INDIVIDUALLY embeddable: <bp-research-header> and the
|
||||
* cap-2 <people-groups-grid> are also registered as standalone custom elements
|
||||
* (see index.js) — one code path, two mount modes.
|
||||
*
|
||||
* @profile big-picture
|
||||
* @bundle big-picture
|
||||
* @element bp-big-picture (this composite) + bp-research-header + people-groups-grid (nested)
|
||||
*
|
||||
* Becomes tab-1 of the Cat-5 SPA.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BpResearchHeader from './src/components/BpResearchHeader.vue'
|
||||
// The REAL cap-2 grid — same component the standalone <people-groups-grid> ships (no fork).
|
||||
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
|
||||
|
||||
// ── profile-config prop (optional overrides) ─────────────────────────────────
|
||||
const props = defineProps({
|
||||
profileConfig: { type: String, default: '' },
|
||||
})
|
||||
const config = computed(() => {
|
||||
if (!props.profileConfig) return {}
|
||||
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
||||
})
|
||||
const accent = computed(() => config.value.accent || '#8b5cf6')
|
||||
const heading = computed(() => config.value.title || 'Big Picture — Unified Research Dashboard')
|
||||
const prayBaseUrl = computed(() => config.value.prayBaseUrl || '')
|
||||
const languageCode = computed(() => config.value.languageCode || '')
|
||||
|
||||
// ── Cross-element wiring state (props/events, no shared bus) ──────────────────
|
||||
// filter: set by the HEADER via its `research-filter` event, read by the GRID as props.
|
||||
// The FOUR real grid axes (Country excluded per Driver).
|
||||
const filter = reactive({ query: '', wagfRegion: '', wagfBlock: '', rop1: '', religion: '' })
|
||||
// matched: set by the GRID via its `groups-filtered` event, read by the HEADER as :count.
|
||||
const matched = ref(null)
|
||||
|
||||
function onResearchFilter(payload) {
|
||||
const d = payload && typeof payload === 'object' ? payload : {}
|
||||
filter.query = d.query || ''
|
||||
filter.wagfRegion = d.wagfRegion || ''
|
||||
filter.wagfBlock = d.wagfBlock || ''
|
||||
filter.rop1 = d.rop1 || ''
|
||||
filter.religion = d.religion || ''
|
||||
}
|
||||
function onGroupsFiltered(payload) {
|
||||
const d = payload && typeof payload === 'object' ? payload : {}
|
||||
matched.value = typeof d.total === 'number' ? d.total : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bp-root" :style="{ '--accent': accent }">
|
||||
<!-- TOP: the Cat-1 dashboard header (SHARED SearchBar + 4 FilterDropdowns). @research-filter
|
||||
is the events half of the contract — the header tells this parent what to filter by. -->
|
||||
<BpResearchHeader
|
||||
:title="heading"
|
||||
:accent="accent"
|
||||
:count="matched"
|
||||
:pray-base-url="prayBaseUrl"
|
||||
:language-code="languageCode"
|
||||
@research-filter="onResearchFilter"
|
||||
/>
|
||||
|
||||
<!-- BELOW: the REAL cap-2 people-groups grid, its OWN controls TURNED OFF so it is driven
|
||||
entirely by the props this parent pushes down. @groups-filtered flows the live total
|
||||
back up into the header's count. -->
|
||||
<div class="bp-body">
|
||||
<PeopleGroupsGrid
|
||||
:query="filter.query"
|
||||
:wagf-region="filter.wagfRegion"
|
||||
:wagf-block="filter.wagfBlock"
|
||||
:rop1="filter.rop1"
|
||||
:religion="filter.religion"
|
||||
:show-search="false"
|
||||
:show-filters="false"
|
||||
:pray-base-url="prayBaseUrl"
|
||||
:language-code="languageCode"
|
||||
@groups-filtered="onGroupsFiltered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="bp-foot">
|
||||
Cap-4 nested-element plugin · header↔grid via props/events (no shared bus) · tab-1 of the SPA
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bp-root {
|
||||
--bg: #0b0f14; --line: #1f2937; --ink: #e5e7eb; --mut: #94a3b8;
|
||||
display: flex; flex-direction: column;
|
||||
width: 100%; height: 100%; min-height: 480px; overflow: hidden;
|
||||
background: var(--bg); color: var(--ink); box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
.bp-body { flex: 1; min-height: 0; position: relative; overflow: auto; }
|
||||
.bp-foot { flex: none; padding: 8px 12px; font-size: 11px; color: var(--mut); text-align: center;
|
||||
border-top: 1px solid var(--line); }
|
||||
</style>
|
||||
86
plugin-designs/big-picture/index.html
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · big-picture staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Capability-4 nested-elements dashboard: NO Mapbox / MapLibre peer needed. Everything is
|
||||
self-contained (in-memory people-groups), so it all renders instantly. -->
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 48px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 780px; }
|
||||
.sub code { color: #c4b5fd; }
|
||||
h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #94a3b8; margin: 30px 0 10px; }
|
||||
.slot { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
.slot .label { font: 12px/1 ui-monospace, monospace; color: #9ca3af; padding: 8px 12px;
|
||||
background: #111827; border-bottom: 1px solid #1f2937; }
|
||||
.frame { position: relative; height: 680px; }
|
||||
bp-big-picture { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
/* Direct-JS host-page wiring demo: two SEPARATE nested elements, wired by THIS page. */
|
||||
.stack { display: flex; flex-direction: column; height: 620px; }
|
||||
.stack .cell.head { flex: none; }
|
||||
.stack .cell.grid { flex: 1; position: relative; border-top: 1px solid #1f2937; }
|
||||
bp-research-header { display: block; }
|
||||
people-groups-grid { display: block; position: absolute; inset: 0; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>big-picture · staging</h1>
|
||||
<p class="sub">Capability 4 — one folder → one bundle → <strong>three</strong> custom
|
||||
elements. The composite <code><bp-big-picture></code> stacks the Cat-1
|
||||
<code><bp-research-header></code> (search + dropdowns) ON TOP of the Cat-2
|
||||
<code><people-groups-grid></code> (the REAL live grid) and wires them via
|
||||
<strong>props/events</strong>: searching / picking a dropdown in the header filters the
|
||||
grid below it, and the header shows the live matched count. The grid's OWN search +
|
||||
dropdowns are turned off in the composite (<code>show-search="false"</code>) so it is
|
||||
driven purely by props. Proves deployment method 3 (direct-JS + props/events + SRI).</p>
|
||||
|
||||
<h2>1 · The composite dashboard (nested elements wired internally)</h2>
|
||||
<div class="slot">
|
||||
<div class="label"><bp-big-picture> — header event → parent → table props</div>
|
||||
<div class="frame">
|
||||
<bp-big-picture id="big-picture-slot"></bp-big-picture>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>2 · Direct-JS host-page wiring (each element embedded separately, wired by THIS page)</h2>
|
||||
<div class="slot">
|
||||
<div class="label">host page listens for the header's <code>research-filter</code> event and sets the grid's <code>query</code>/<code>wagf-region</code>/<code>wagf-block</code>/<code>rop1</code>/<code>religion</code> attributes</div>
|
||||
<div class="frame" style="height:auto;padding:12px;">
|
||||
<div class="stack">
|
||||
<div class="cell head"><bp-research-header id="h"></bp-research-header></div>
|
||||
<div class="cell grid"><people-groups-grid id="t" show-search="false" show-filters="false"></people-groups-grid></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vite multi-page entry — emits the IIFE that registers all three custom elements. -->
|
||||
<script type="module" src="./index.js"></script>
|
||||
|
||||
<!-- Deploy-method-3 proof: a PLAIN host page wires the two standalone nested elements
|
||||
together through the DOM (header event → table attributes). No bundler code touched. -->
|
||||
<script type="module">
|
||||
const h = document.getElementById('h')
|
||||
const t = document.getElementById('t')
|
||||
if (h && t) {
|
||||
h.addEventListener('research-filter', (e) => {
|
||||
const d = (e.detail || {})
|
||||
d.query ? t.setAttribute('query', d.query) : t.removeAttribute('query')
|
||||
d.wagfRegion ? t.setAttribute('wagf-region', d.wagfRegion) : t.removeAttribute('wagf-region')
|
||||
d.wagfBlock ? t.setAttribute('wagf-block', d.wagfBlock) : t.removeAttribute('wagf-block')
|
||||
d.rop1 ? t.setAttribute('rop1', d.rop1) : t.removeAttribute('rop1')
|
||||
d.religion ? t.setAttribute('religion', d.religion) : t.removeAttribute('religion')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
58
plugin-designs/big-picture/index.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* big-picture / index.js — Bundle entry (Capability-4 nested-elements-mfe).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> THREE custom elements:
|
||||
* 1. <bp-big-picture> — the COMPOSITE. Nests the research header (top) + the REAL cap-2
|
||||
* people-groups grid (below) and wires them via props/events. Registered
|
||||
* FIRST so discover.mjs (reads the FIRST customElements.define in
|
||||
* source) treats this as the bundle's headline tag.
|
||||
* 2. <bp-research-header> — the Cat-1 SHARED-control header (SearchBar + 4 FilterDropdowns),
|
||||
* ALSO registered standalone => individually embeddable; drives a grid
|
||||
* via `research-filter` { query, wagfRegion, wagfBlock, rop1, religion }.
|
||||
* 3. <people-groups-grid> — the REAL Cat-2 grid (same component the people-groups-grid bundle
|
||||
* ships — NO fork), ALSO registered standalone. Set
|
||||
* show-search="false"/show-filters="false" to drive it via props.
|
||||
*
|
||||
* All three register in one synchronous tick; the composite is listed first ONLY so discover.mjs
|
||||
* names the bundle after it. Each element is its own root wrapped with defineCustomElement, so the
|
||||
* built IIFE renders anywhere HTML+JS runs (the grid fetches live people-groups from pray.doxa.life).
|
||||
*
|
||||
* Usage — composite (direct-JS, method 3):
|
||||
* <script src=".../big-picture/big-picture.js"></script>
|
||||
* <bp-big-picture></bp-big-picture>
|
||||
*
|
||||
* Usage — header + grid alone, wired by a host page (also method 3):
|
||||
* <bp-research-header id="h"></bp-research-header>
|
||||
* <people-groups-grid id="t" show-search="false" show-filters="false"></people-groups-grid>
|
||||
* <script>
|
||||
* h.addEventListener('research-filter', e => {
|
||||
* const { query, wagfRegion, wagfBlock, rop1, religion } = e.detail
|
||||
* query ? t.setAttribute('query', query) : t.removeAttribute('query')
|
||||
* wagfRegion ? t.setAttribute('wagf-region', wagfRegion) : t.removeAttribute('wagf-region')
|
||||
* wagfBlock ? t.setAttribute('wagf-block', wagfBlock) : t.removeAttribute('wagf-block')
|
||||
* rop1 ? t.setAttribute('rop1', rop1) : t.removeAttribute('rop1')
|
||||
* religion ? t.setAttribute('religion', religion) : t.removeAttribute('religion')
|
||||
* })
|
||||
* </script>
|
||||
*/
|
||||
|
||||
import { defineCustomElement } from 'vue'
|
||||
import BigPicture from './big-picture.vue'
|
||||
import ResearchHeader from './src/components/BpResearchHeader.vue'
|
||||
// The REAL cap-2 grid component (no fork) — registered here so it is individually embeddable
|
||||
// from THIS bundle too, exactly as the people-groups-grid bundle registers it.
|
||||
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
|
||||
|
||||
const BigPictureElement = defineCustomElement(BigPicture)
|
||||
const ResearchHeaderElement = defineCustomElement(ResearchHeader)
|
||||
const PeopleGroupsGridElement = defineCustomElement(PeopleGroupsGrid)
|
||||
|
||||
if (typeof customElements !== 'undefined') {
|
||||
// Composite FIRST — this is the discover.mjs headline-tag signal.
|
||||
if (!customElements.get('bp-big-picture')) customElements.define('bp-big-picture', BigPictureElement)
|
||||
// Nested elements standalone — individually embeddable.
|
||||
if (!customElements.get('bp-research-header')) customElements.define('bp-research-header', ResearchHeaderElement)
|
||||
if (!customElements.get('people-groups-grid')) customElements.define('people-groups-grid', PeopleGroupsGridElement)
|
||||
}
|
||||
|
||||
export default BigPictureElement
|
||||
169
plugin-designs/big-picture/src/components/BpGroupsTable.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script setup>
|
||||
/**
|
||||
* BpGroupsTable.vue — the Cat-2 people-groups PROP-TABLE (nested element of Big Picture).
|
||||
*
|
||||
* This is the "people-groups grid + its OWN search bar + dropdowns" from the research page,
|
||||
* lifted into a self-contained, individually-embeddable custom element (<bp-groups-table>).
|
||||
*
|
||||
* The Cat-2 contract (the whole point of this element):
|
||||
* - By DEFAULT it owns its internal search box + region/status dropdowns and filters
|
||||
* itself — drop it on any page and it just works standalone.
|
||||
* - Props can TURN OFF those internal controls so it is driven EXTERNALLY:
|
||||
* show-search="false" hides the internal search box
|
||||
* show-filters="false" hides the internal region/status dropdowns
|
||||
* …and then the parent (the Big Picture dashboard header, or any host page) feeds it
|
||||
* the `query` / `region` / `status` props instead. One element, two drive modes.
|
||||
* - It EMITS `groups-filtered` (detail = { query, region, status, count, slugs }) every
|
||||
* time the visible set changes, so an external driver can reflect the count/selection.
|
||||
* Emitted BOTH as a Vue emit AND as a native bubbling+composed DOM CustomEvent, so a
|
||||
* plain host page hears it when the element is embedded standalone.
|
||||
*
|
||||
* Precedence of a filter value = internal control (when shown) ELSE the incoming prop.
|
||||
* So turning a control off cedes that axis to the prop; leaving it on lets the user drive it.
|
||||
*/
|
||||
import { computed, ref, watch, getCurrentInstance } from 'vue'
|
||||
import {
|
||||
PEOPLE_GROUPS, REGIONS, STATUSES,
|
||||
regionName, statusName, statusOf, filterGroups,
|
||||
} from '../data/people-groups.js'
|
||||
|
||||
const props = defineProps({
|
||||
// externally-driven filter axes (used when the matching internal control is hidden).
|
||||
query: { type: String, default: '' },
|
||||
region: { type: String, default: '' },
|
||||
status: { type: String, default: '' },
|
||||
// toggles for the element's OWN controls. Accept string 'false' too (DOM attributes).
|
||||
showSearch: { type: [Boolean, String], default: true },
|
||||
showFilters: { type: [Boolean, String], default: true },
|
||||
})
|
||||
const emit = defineEmits(['groups-filtered'])
|
||||
|
||||
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
|
||||
const useSearch = computed(() => truthy(props.showSearch))
|
||||
const useFilters = computed(() => truthy(props.showFilters))
|
||||
|
||||
// internal control state (only authoritative when the control is shown).
|
||||
const localQuery = ref('')
|
||||
const localRegion = ref('')
|
||||
const localStatus = ref('')
|
||||
|
||||
// effective filter = internal control (if shown) else the prop.
|
||||
const effQuery = computed(() => (useSearch.value ? localQuery.value : (props.query || '')))
|
||||
const effRegion = computed(() => (useFilters.value ? localRegion.value : (props.region || '')))
|
||||
const effStatus = computed(() => (useFilters.value ? localStatus.value : (props.status || '')))
|
||||
|
||||
const rows = computed(() =>
|
||||
filterGroups({ query: effQuery.value, region: effRegion.value, status: effStatus.value })
|
||||
.slice()
|
||||
.sort((a, b) => a.reached - b.reached) // least-reached first
|
||||
)
|
||||
|
||||
// ── emit `groups-filtered` whenever the visible set changes (Vue + DOM) ──────────
|
||||
const inst = getCurrentInstance()
|
||||
function hostEl() {
|
||||
const root = inst?.vnode?.el || inst?.proxy?.$el
|
||||
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return root
|
||||
}
|
||||
watch(rows, (r) => {
|
||||
const detail = {
|
||||
query: effQuery.value, region: effRegion.value, status: effStatus.value,
|
||||
count: r.length, slugs: r.map((g) => g.slug),
|
||||
}
|
||||
emit('groups-filtered', detail)
|
||||
const el = hostEl()
|
||||
if (el && typeof el.dispatchEvent === 'function') {
|
||||
el.dispatchEvent(new CustomEvent('groups-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const fmtPop = (n) => {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(0) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
const total = PEOPLE_GROUPS.length
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gt-root">
|
||||
<!-- The element's OWN controls: shown by default, hidden when driven externally. -->
|
||||
<div v-if="useSearch || useFilters" class="gt-controls">
|
||||
<div v-if="useSearch" class="gt-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input v-model="localQuery" type="search" placeholder="Search people groups…"
|
||||
aria-label="Search people groups" />
|
||||
</div>
|
||||
<template v-if="useFilters">
|
||||
<select v-model="localRegion" aria-label="Filter by region">
|
||||
<option value="">All regions</option>
|
||||
<option v-for="r in REGIONS" :key="r.id" :value="r.id">{{ r.name }}</option>
|
||||
</select>
|
||||
<select v-model="localStatus" aria-label="Filter by reach status">
|
||||
<option value="">All status</option>
|
||||
<option v-for="s in STATUSES" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="gt-summary">
|
||||
<strong>{{ rows.length }}</strong> / {{ total }} people groups
|
||||
<span v-if="effRegion"> · {{ regionName(effRegion) }}</span>
|
||||
<span v-if="effStatus"> · {{ statusName(effStatus) }}</span>
|
||||
<span v-if="effQuery"> · “{{ effQuery }}”</span>
|
||||
</div>
|
||||
|
||||
<div class="gt-tablewrap">
|
||||
<table class="gt-table">
|
||||
<thead>
|
||||
<tr><th>People group</th><th>Country</th><th>Region</th><th class="num">Population</th><th class="num">Reached</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pg in rows" :key="pg.slug">
|
||||
<td><span class="gt-dot" :class="statusOf(pg.reached)" aria-hidden="true"></span>{{ pg.name }}</td>
|
||||
<td>{{ pg.country }}</td>
|
||||
<td>{{ regionName(pg.region) }}</td>
|
||||
<td class="num">{{ fmtPop(pg.population) }}</td>
|
||||
<td class="num" :class="statusOf(pg.reached)">{{ pg.reached }}%</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length"><td colspan="5" class="gt-empty">No people groups match this filter.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.gt-root { width: 100%; height: 100%; display: flex; flex-direction: column;
|
||||
background: #0b0f14; color: #e5e7eb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; box-sizing: border-box; }
|
||||
.gt-controls { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px;
|
||||
border-bottom: 1px solid #1f2937; }
|
||||
.gt-search { position: relative; display: flex; align-items: center; flex: 1; min-width: 180px; color: #94a3b8; }
|
||||
.gt-search svg { position: absolute; left: 9px; pointer-events: none; }
|
||||
.gt-search input { width: 100%; padding: 7px 10px 7px 30px; background: #111827;
|
||||
border: 1px solid #1f2937; border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
|
||||
.gt-search input:focus { border-color: #8b5cf6; }
|
||||
.gt-controls select { padding: 7px 10px; background: #111827; border: 1px solid #1f2937;
|
||||
border-radius: 8px; color: #e5e7eb; font-size: 13px; outline: none; }
|
||||
.gt-controls select:focus { border-color: #8b5cf6; }
|
||||
.gt-summary { padding: 8px 12px; font-size: 12px; color: #94a3b8; border-bottom: 1px solid #1f2937; }
|
||||
.gt-summary strong { color: #8b5cf6; }
|
||||
.gt-tablewrap { flex: 1; min-height: 0; overflow: auto; }
|
||||
.gt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.gt-table thead th { position: sticky; top: 0; z-index: 1; text-align: left;
|
||||
padding: 8px 12px; background: #111827; color: #94a3b8; font: 700 11px/1 ui-monospace, monospace;
|
||||
letter-spacing: .06em; text-transform: uppercase; border-bottom: 1px solid #1f2937; }
|
||||
.gt-table th.num, .gt-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.gt-table tbody td { padding: 8px 12px; border-bottom: 1px solid #141c28; }
|
||||
.gt-table tbody tr:hover { background: #111827; }
|
||||
.gt-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
|
||||
.gt-dot.frontier, td.frontier { color: #ef4444; } .gt-dot.frontier { background: #ef4444; }
|
||||
.gt-dot.unreached, td.unreached { color: #f59e0b; } .gt-dot.unreached { background: #f59e0b; }
|
||||
.gt-dot.reached, td.reached { color: #10b981; } .gt-dot.reached { background: #10b981; }
|
||||
.gt-empty { padding: 20px; text-align: center; color: #64748b; }
|
||||
</style>
|
||||
197
plugin-designs/big-picture/src/components/BpResearchHeader.vue
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script setup>
|
||||
/**
|
||||
* BpResearchHeader.vue — the Cat-1 Unified Research Dashboard header (nested element).
|
||||
*
|
||||
* The "search bar + dropdowns ON TOP" that drives the people-groups grid BELOW it.
|
||||
* Lifted out into its own individually-embeddable element (<bp-research-header>) so it can
|
||||
* DRIVE the cap-2 people-groups grid (or any host page's grid) via props/events.
|
||||
*
|
||||
* ── REFACTOR (card BUILD-G1-CAT4-BIG-PICTURE-DASHBOARD) ──────────────────────────
|
||||
* The hand-rolled `<input type="search">` + the two native `<select>` dropdowns
|
||||
* (the "glitchy search bars") are GONE. They are replaced by the SHARED, one-source
|
||||
* controls consumed by BOTH plugins so there is no duplication:
|
||||
* - `<SearchBar>` (@map/components/search/SearchBar.vue) — on top.
|
||||
* - FOUR `<FilterDropdown>` (@map/components/search/FilterDropdown.vue) below it:
|
||||
* Region / Block / People-Group / Religion. (Country EXCLUDED per Driver.)
|
||||
* These are the SAME four axes the cap-2 people-groups-grid exposes, so the header
|
||||
* drives the real grid 1:1.
|
||||
*
|
||||
* THE CAT-1 DRIVER CONTRACT
|
||||
* - Owns a search box + the four filter dropdowns.
|
||||
* - EMITS `research-filter` on every change — the SAME event name as before, but the
|
||||
* payload is EXTENDED to carry the four dropdown values (the two old {region,status}
|
||||
* synthetic facets are replaced by the four real grid axes):
|
||||
* { query, wagfRegion, wagfBlock, rop1, religion }
|
||||
* Emitted BOTH as a Vue emit AND as a native bubbling+composed DOM CustomEvent so a
|
||||
* plain host page (deploy-method-3, direct-JS) hears it and can drive a standalone grid.
|
||||
* - Accepts an optional `count` prop so the composite can reflect the live matched count
|
||||
* back up into the header (data flows down, event flows up — no shared bus).
|
||||
*
|
||||
* DROPDOWN OPTIONS (real, faceted)
|
||||
* The header fetches the SAME live people-groups list the cap-2 grid fetches
|
||||
* (`${prayBaseUrl}/api/people-groups/list`, default https://pray.doxa.life) and extracts
|
||||
* the option lists + counts for the four axes with the SAME extraction the grid uses.
|
||||
* This keeps the grid UNTOUCHED (this plugin owns only its own folder) while giving the
|
||||
* header dropdowns real values that match the grid exactly. If the fetch fails the header
|
||||
* still renders (empty dropdowns degrade to search-only) — the grid remains authoritative.
|
||||
*/
|
||||
import { ref, reactive, watch, computed, onMounted, getCurrentInstance } from 'vue'
|
||||
// SHARED search primitives (one source of truth for BOTH cap-2 grid + cap-1 dashboard).
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
|
||||
const props = defineProps({
|
||||
// live count of matched groups, pushed back down by the composite for display.
|
||||
count: { type: [Number, String], default: null },
|
||||
title: { type: String, default: 'Unified Research Dashboard' },
|
||||
accent: { type: String, default: '#8b5cf6' },
|
||||
// injected data host so the header builds its dropdown options from the SAME source the grid uses.
|
||||
prayBaseUrl: { type: String, default: '' },
|
||||
languageCode: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['research-filter'])
|
||||
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || '')
|
||||
|
||||
// ── unified filter state ─────────────────────────────────────────────────────
|
||||
const query = ref('')
|
||||
// the FOUR grid axes (Country excluded per Driver): Region / Block / People-Group / Religion.
|
||||
const sel = reactive({ wagf_region: '', wagf_block: '', rop1: '', religion: '' })
|
||||
|
||||
// option lists (with faceted counts) built from the live list — the SAME shape FilterDropdown wants.
|
||||
const options = reactive({ wagf_region: [], wagf_block: [], rop1: [], religion: [] })
|
||||
|
||||
const hasAny = computed(() => Boolean(query.value || sel.wagf_region || sel.wagf_block || sel.rop1 || sel.religion))
|
||||
|
||||
// ── native + Vue emit of the unified filter ──────────────────────────────────
|
||||
const inst = getCurrentInstance()
|
||||
function hostEl() {
|
||||
const root = inst?.vnode?.el || inst?.proxy?.$el
|
||||
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return root
|
||||
}
|
||||
function fire() {
|
||||
const detail = {
|
||||
query: query.value,
|
||||
wagfRegion: sel.wagf_region,
|
||||
wagfBlock: sel.wagf_block,
|
||||
rop1: sel.rop1,
|
||||
religion: sel.religion,
|
||||
}
|
||||
emit('research-filter', detail)
|
||||
const el = hostEl()
|
||||
if (el && typeof el.dispatchEvent === 'function') {
|
||||
el.dispatchEvent(new CustomEvent('research-filter', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
}
|
||||
// fire on ANY control change (and once on mount so a driven grid starts in sync).
|
||||
watch([query, () => sel.wagf_region, () => sel.wagf_block, () => sel.rop1, () => sel.religion], fire, { immediate: true })
|
||||
|
||||
// ── shared-control event handlers ────────────────────────────────────────────
|
||||
function onSearch(payload) { query.value = payload?.value ?? '' }
|
||||
function onFilterChange(detail) { if (detail?.name in sel) sel[detail.name] = detail.value }
|
||||
function onFilterClear(detail) { if (detail?.name in sel) sel[detail.name] = '' }
|
||||
|
||||
function reset() {
|
||||
query.value = ''
|
||||
sel.wagf_region = ''; sel.wagf_block = ''; sel.rop1 = ''; sel.religion = ''
|
||||
}
|
||||
|
||||
// ── build the four dropdown option lists from the SAME live list the grid fetches ──
|
||||
function extractFrom(list, field) {
|
||||
const counts = new Map()
|
||||
for (const u of list) {
|
||||
const vl = u[field]
|
||||
if (!vl?.value) continue
|
||||
const existing = counts.get(vl.value)
|
||||
if (existing) existing.count++
|
||||
else counts.set(vl.value, { label: vl.label, count: 1 })
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([value, { label, count }]) => ({ value, label, count }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion&lang=${languageCode.value}`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
const list = Array.isArray(data.posts) ? data.posts : []
|
||||
options.wagf_region = extractFrom(list, 'wagf_region')
|
||||
options.wagf_block = extractFrom(list, 'wagf_block')
|
||||
options.rop1 = extractFrom(list, 'rop1')
|
||||
options.religion = extractFrom(list, 'religion')
|
||||
} catch (error) {
|
||||
// Non-fatal: header still renders; dropdowns degrade to empty, search stays live.
|
||||
console.error('[bp-research-header] options fetch error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rh-root" :style="{ '--accent': accent }">
|
||||
<div class="rh-title">
|
||||
<span class="rh-dot" aria-hidden="true"></span>
|
||||
<h1>{{ title }}</h1>
|
||||
<span v-if="count !== null && count !== ''" class="rh-count">{{ count }} shown</span>
|
||||
</div>
|
||||
|
||||
<!-- SHARED SearchBar on top (replaces the hand-rolled <input type="search">). -->
|
||||
<div class="rh-search">
|
||||
<SearchBar
|
||||
:model-value="query"
|
||||
placeholder="Search people groups or countries…"
|
||||
aria-label="Search people groups"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- The FOUR SHARED FilterDropdowns (replace the two native <select>s). -->
|
||||
<div class="rh-controls">
|
||||
<FilterDropdown
|
||||
label="Region" name="wagf_region"
|
||||
:options="options.wagf_region" :value="sel.wagf_region"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Block" name="wagf_block"
|
||||
:options="options.wagf_block" :value="sel.wagf_block"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="People Group" name="rop1"
|
||||
:options="options.rop1" :value="sel.rop1"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Religion" name="religion"
|
||||
:options="options.religion" :value="sel.religion"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<button v-if="hasAny" class="rh-reset" type="button" @click="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.rh-root { width: 100%; box-sizing: border-box; padding: 14px 16px;
|
||||
background: #111827; color: #e5e7eb; border-bottom: 1px solid #1f2937;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex; flex-direction: column; gap: 12px; }
|
||||
.rh-title { display: flex; align-items: center; gap: 10px; }
|
||||
.rh-title h1 { font-size: 17px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.rh-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent); }
|
||||
.rh-count { margin-left: auto; font: 700 12px/1 ui-monospace, monospace; color: var(--accent); }
|
||||
/* The search box + dropdowns are the SHARED @map controls (self-styling in the shadow root);
|
||||
these wrappers only handle layout inside the dark header. */
|
||||
.rh-search { display: flex; }
|
||||
.rh-controls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
|
||||
.rh-reset { padding: 8px 12px; background: transparent; border: 1px solid #1f2937;
|
||||
border-radius: 8px; color: #94a3b8; font-size: 13px; cursor: pointer; }
|
||||
.rh-reset:hover { border-color: var(--accent); color: #e5e7eb; }
|
||||
</style>
|
||||
81
plugin-designs/big-picture/src/data/people-groups.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* people-groups.js — the Big Picture dataset (self-contained, token-free).
|
||||
*
|
||||
* Shared by BOTH inner elements of the "Big Picture" Cat-4 plugin:
|
||||
* - BpGroupsTable.vue (the Cat-2 people-groups PROP-TABLE) renders + filters this.
|
||||
* - BpResearchHeader.vue (the Cat-1 search/dropdown dashboard header) builds its
|
||||
* dropdown option lists (regions, reach-status) from this same catalogue.
|
||||
*
|
||||
* `region` + `status` are the join keys the header's dropdowns filter on; the header
|
||||
* pushes {query, region, status} DOWN to the table as props (or a host page drives the
|
||||
* table's own attributes directly). No remote fetch, no map token — renders instantly.
|
||||
*/
|
||||
|
||||
export const REGIONS = [
|
||||
{ id: 'na', name: 'North America' },
|
||||
{ id: 'sa', name: 'South America' },
|
||||
{ id: 'eu', name: 'Europe' },
|
||||
{ id: 'af', name: 'Africa' },
|
||||
{ id: 'me', name: 'Middle East' },
|
||||
{ id: 'ca', name: 'Central Asia' },
|
||||
{ id: 'sas', name: 'South Asia' },
|
||||
{ id: 'sea', name: 'Southeast Asia' },
|
||||
{ id: 'ea', name: 'East Asia' },
|
||||
]
|
||||
|
||||
// reach-status buckets (the second dropdown facet). `reached` is % believers proxy.
|
||||
export const STATUSES = [
|
||||
{ id: 'frontier', name: 'Frontier (<0.1%)' },
|
||||
{ id: 'unreached', name: 'Unreached (0.1–2%)' },
|
||||
{ id: 'reached', name: 'Reached (>2%)' },
|
||||
]
|
||||
|
||||
export function statusOf(reached) {
|
||||
return reached < 0.1 ? 'frontier' : reached < 2 ? 'unreached' : 'reached'
|
||||
}
|
||||
|
||||
export const PEOPLE_GROUPS = [
|
||||
{ slug: 'hui', name: 'Hui', country: 'China', region: 'ea', population: 11700000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'uyghur', name: 'Uyghur', country: 'China', region: 'ca', population: 11500000, reached: 0.05, jp: 3.2 },
|
||||
{ slug: 'yadava', name: 'Yadava', country: 'India', region: 'sas', population: 60000000, reached: 0.2, jp: 3.0 },
|
||||
{ slug: 'shaikh', name: 'Shaikh', country: 'India', region: 'sas', population: 96000000, reached: 0.01, jp: 3.4 },
|
||||
{ slug: 'brahmin', name: 'Brahmin', country: 'India', region: 'sas', population: 58000000, reached: 0.3, jp: 2.8 },
|
||||
{ slug: 'bengali', name: 'Bengali', country: 'Bangladesh', region: 'sas', population: 148000000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'javanese', name: 'Javanese', country: 'Indonesia', region: 'sea', population: 96000000, reached: 1.2, jp: 2.6 },
|
||||
{ slug: 'sundanese', name: 'Sundanese', country: 'Indonesia', region: 'sea', population: 38000000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'thai', name: 'Central Thai', country: 'Thailand', region: 'sea', population: 22000000, reached: 0.5, jp: 2.8 },
|
||||
{ slug: 'persian', name: 'Persian', country: 'Iran', region: 'me', population: 39000000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'arab-najdi', name: 'Najdi Arab', country: 'Saudi Arabia', region: 'me', population: 11000000, reached: 0.02, jp: 3.4 },
|
||||
{ slug: 'kurd', name: 'Kurd', country: 'Iraq', region: 'me', population: 8500000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'turk', name: 'Turk', country: 'Turkey', region: 'me', population: 59000000, reached: 0.2, jp: 3.0 },
|
||||
{ slug: 'kazakh', name: 'Kazakh', country: 'Kazakhstan', region: 'ca', population: 13000000, reached: 0.3, jp: 2.9 },
|
||||
{ slug: 'uzbek', name: 'Uzbek', country: 'Uzbekistan', region: 'ca', population: 30000000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'somali', name: 'Somali', country: 'Somalia', region: 'af', population: 17000000, reached: 0.05, jp: 3.3 },
|
||||
{ slug: 'fulani', name: 'Fulani', country: 'Nigeria', region: 'af', population: 38000000, reached: 0.2, jp: 3.1 },
|
||||
{ slug: 'hausa', name: 'Hausa', country: 'Nigeria', region: 'af', population: 65000000, reached: 0.3, jp: 3.0 },
|
||||
{ slug: 'berber', name: 'Shilha Berber', country: 'Morocco', region: 'af', population: 9000000, reached: 0.1, jp: 3.1 },
|
||||
{ slug: 'wolof', name: 'Wolof', country: 'Senegal', region: 'af', population: 6500000, reached: 0.1, jp: 3.2 },
|
||||
{ slug: 'tibetan', name: 'Tibetan', country: 'China', region: 'ea', population: 6300000, reached: 0.2, jp: 3.1 },
|
||||
{ slug: 'han-chinese',name: 'Han (Mandarin)', country: 'China', region: 'ea', population: 900000000, reached: 5.1, jp: 2.1 },
|
||||
{ slug: 'quechua', name: 'Quechua', country: 'Peru', region: 'sa', population: 8000000, reached: 3.0, jp: 2.3 },
|
||||
{ slug: 'roma', name: 'Roma', country: 'Romania', region: 'eu', population: 3000000, reached: 1.0, jp: 2.6 },
|
||||
{ slug: 'bosniak', name: 'Bosniak', country: 'Bosnia', region: 'eu', population: 2500000, reached: 0.4, jp: 2.9 },
|
||||
{ slug: 'navajo', name: 'Navajo', country: 'USA', region: 'na', population: 330000, reached: 4.0, jp: 2.2 },
|
||||
]
|
||||
|
||||
export const regionName = (id) => (REGIONS.find(r => r.id === id) || {}).name || id
|
||||
export const statusName = (id) => (STATUSES.find(s => s.id === id) || {}).name || id
|
||||
|
||||
/**
|
||||
* The ONE shared filter predicate. Both the header's live-count and the table use it, so
|
||||
* the count the header shows always matches what the table renders. Pure — no state.
|
||||
*/
|
||||
export function filterGroups({ query = '', region = '', status = '' } = {}) {
|
||||
const q = (query || '').trim().toLowerCase()
|
||||
return PEOPLE_GROUPS.filter((pg) => {
|
||||
if (region && pg.region !== region) return false
|
||||
if (status && statusOf(pg.reached) !== status) return false
|
||||
if (q && !(`${pg.name} ${pg.country}`.toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
@ -469,7 +469,7 @@ const geocoderPlaceholder = computed(() => {
|
|||
// Search context (Round 16) — maps the active legend tab to the category the
|
||||
// unified search bar should surface FIRST. The three people-centric color tabs
|
||||
// (prayer / adoption / engagement) share the people-groups context since they
|
||||
// all paint people-group pins. null → useDoxaSearch uses its default order.
|
||||
// all paint people-group pins. null → useEntitySearch uses its default order.
|
||||
const TAB_SEARCH_CONTEXT = {
|
||||
'affinity-blocks': 'people-groups',
|
||||
'prayer': 'people-groups',
|
||||
|
|
|
|||
16
plugin-designs/doxa-maps-spa/doxa-maps-spa.instances.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the Cat-5 app-shell <doxa-maps-spa>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs mapPageHtml → TOKEN_FREE) so the app-shell element mounts INSTANTLY — correct because the SHELL ITSELF paints no map (its TABS do, each in its own document/iframe with its own peer + token ladder). Without this flag the generic leaf-page harness gates on a Mapbox token the shell never uses and the element never mounts. The meta.json's type:'nested-mfe-spa' (Cat-5) still wins over the instances' parameterized signal (discover.detectProfileType: explicit meta type takes precedence), so this stays a Cat-5 app-shell, not a Cat-2 preset. tree-base is left to the runtime derivation (the leaf page's own URL → tree root), so no absolute host is baked in — the shell self-locates wherever the tree is served.",
|
||||
"profile": "doxa-maps-spa",
|
||||
"instances": [
|
||||
{
|
||||
"id": "app",
|
||||
"title": "DX-Maps SPA — Big Picture + every research map (app-shell)",
|
||||
"props": {
|
||||
"profile": "doxa-maps-spa",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "doxa-maps-spa-app",
|
||||
"title": "DX-Maps"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
13
plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app-shell of the whole DX-maps app. Its single element <doxa-maps-spa> renders a VERTICAL-TAB layout that nests the OTHER plugins as its tabs — tab-1 = the Cat-4 Big Picture (mounted directly via its real <bp-big-picture> tag + its built .js, token-free), the other tabs = each production research map, nested as its OWN full SPA via that map's standalone leaf page (iframe, so each map keeps its own peer-engine + Mapbox-token ladder + profile-config and there is NO customElements.define collision — the one-shot-define hazard is avoided by giving each map its own document). This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it). Filename must be <profile>.meta.json where profile = the root .vue name (doxa-maps-spa). generate-gallery.readSourceTiers() reads this and bakes capability 5 for the plugin, so it appears in the studio manifest as the app-shell (nested-mfe-spa).",
|
||||
"type": "nested-mfe-spa",
|
||||
"composes": [
|
||||
"bp-big-picture",
|
||||
"doxa-research-map",
|
||||
"doxa-simple-map",
|
||||
"cities-internet-map",
|
||||
"gospel-resources-map",
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
"label": "DX-Maps SPA (vertical-tab app-shell: Big Picture + every research map)"
|
||||
}
|
||||
439
plugin-designs/doxa-maps-spa/doxa-maps-spa.vue
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
<!--
|
||||
doxa-maps-spa.vue — the Category-5 (nested-mfe-spa / app-shell) root component.
|
||||
|
||||
ONE element, <doxa-maps-spa>, renders a VERTICAL-TAB layout that nests the other plugins
|
||||
as its tabs. This IS the DX-maps app: the unified place a user opens to reach every research
|
||||
map without getting confused by a dozen separate embeds.
|
||||
|
||||
TAB 1 = Big Picture (Cat-4 composite <bp-big-picture>) — the header (shared SearchBar + 4
|
||||
FilterDropdowns) driving the real people-groups grid. Token-free + self-contained, so it is
|
||||
mounted DIRECTLY via its real tag + its built .js (direct-JS nesting): the SPA injects
|
||||
big-picture/big-picture.js ONCE (guarded), then creates <bp-big-picture>. This honors the
|
||||
card's "load each nested element by its real tag + its built .js".
|
||||
|
||||
TABS 2..N = every production research map — each nested as its OWN full SPA via that map's
|
||||
standalone leaf page inside an <iframe>. WHY an iframe for the map tabs and a direct tag for
|
||||
tab-1? The map bundles each register their OWN customElements.define (one-shot) AND need the
|
||||
Mapbox peer-engine + token ladder; loading many such IIFEs into ONE document is the one-shot-
|
||||
define hazard the research flagged. Giving each map its own document (its leaf page, which is
|
||||
already a fully self-contained SPA with its own peer-engine + token ladder + profile-config)
|
||||
SIDESTEPS that hazard entirely, keeps each map individually correct, and is position-
|
||||
independent (leaf pages resolve everything relative to the tree root). Each iframe is lazy —
|
||||
it only loads when its tab is first activated (src set on demand), so opening the SPA costs
|
||||
exactly one map load (tab-1), not N.
|
||||
|
||||
TREE BASE: the SPA must know where the built tree lives to find each map's .js / leaf page.
|
||||
index.js captures the running <script src=".../doxa-maps/doxa-maps-spa/doxa-maps-spa.js">
|
||||
URL at IIFE load (document.currentScript) and provides its tree root (two levels up). A host
|
||||
can override it with profile-config.treeBase or the tree-base="" attribute. Default falls
|
||||
back to a same-tree relative guess so a plain drop still resolves.
|
||||
|
||||
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
|
||||
owns — the only tag this bundle defines is <doxa-maps-spa> (see index.js). bp-big-picture is
|
||||
registered by big-picture.js when that IIFE loads; this SPA only USES the tag.
|
||||
-->
|
||||
<template>
|
||||
<!-- The SHELL (top bar + vertical tab rail + content frame) is the ONE shared beautiful layout,
|
||||
VerticalTabsLayout (library/layouts). This SPA only supplies the tab manifest + owns the panes:
|
||||
the delicate direct-JS mount of tab-1 and the lazy iframe-per-map behavior are UNCHANGED —
|
||||
they live in the default slot below, driven by activeKey. -->
|
||||
<VerticalTabsLayout
|
||||
:title="title"
|
||||
nav-label="Research maps"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="onSelect">
|
||||
|
||||
<!-- #brand: the DX-Maps logo mark, left of the title (a compass-star glyph, self-drawn SVG — no
|
||||
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
|
||||
<template #brand>
|
||||
<span class="dms-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polygon points="12 6 14 12 12 18 10 12" fill="#00DC82" stroke="none" />
|
||||
<polygon points="6 12 12 10 18 12 12 14" fill="#00DC82" opacity="0.55" stroke="none" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #meta>{{ tabs.length }} maps</template>
|
||||
|
||||
<!-- #toolbar: the i18n locale switcher (3-dots/flag). Reuses app-wrapper-material/languages.js —
|
||||
renders the flag + native name, persists the choice (loadLocale/saveLocale), and re-sets the
|
||||
mounted tab-1 element's profile-config.locale (languages.js's contract). No routing, no dep. -->
|
||||
<template #toolbar>
|
||||
<div class="dms-locale">
|
||||
<button type="button" class="dms-locale-btn" :aria-expanded="String(localeOpen)"
|
||||
aria-label="Change language" @click="localeOpen = !localeOpen">
|
||||
<span class="dms-flag">{{ activeLang.flag }}</span>
|
||||
<span class="dms-locale-name">{{ activeLang.nativeName }}</span>
|
||||
<span class="dms-dots" aria-hidden="true">⋮</span>
|
||||
</button>
|
||||
<ul class="dms-locale-menu" v-if="localeOpen" role="listbox">
|
||||
<li v-for="l in switcherLangs" :key="l.code" role="option"
|
||||
:aria-selected="String(l.code === locale)">
|
||||
<button type="button" class="dms-locale-opt" :class="{ active: l.code === locale }"
|
||||
@click="setLocale(l.code)">
|
||||
<span class="dms-flag">{{ l.flag }}</span>
|
||||
<span class="dms-locale-name">{{ l.nativeName }}</span>
|
||||
<span class="dms-locale-en">{{ l.name }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Every tab gets its own persistent pane; only the active one is shown. Panes stay
|
||||
mounted once loaded so switching back is instant and map state survives. -->
|
||||
<section
|
||||
v-for="(t, i) in tabs" :key="t.id" class="dms-pane"
|
||||
v-show="i === activeIndex" :hidden="i !== activeIndex">
|
||||
|
||||
<!-- TAB 1: direct-tag nesting of the token-free composite. The real <bp-big-picture>
|
||||
is created imperatively into an EMPTY, Vue-unmanaged slot div (:ref, no children)
|
||||
after big-picture.js is injected once — so Vue never fights the custom element's
|
||||
own shadow DOM. The loading/error hint is a SEPARATE overlay sibling, so clearing
|
||||
the slot with innerHTML never removes a Vue-managed node. -->
|
||||
<template v-if="t.mode === 'element'">
|
||||
<div class="dms-slot" :ref="el => setSlot(i, el)"></div>
|
||||
<div v-if="t.state === 'loading'" class="dms-hint">Loading {{ t.label }}…</div>
|
||||
<div v-else-if="t.state === 'error'" class="dms-hint err">
|
||||
Could not load {{ t.label }}.<br><small>{{ t.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TABS 2..N: each map as its own full SPA in a lazy iframe (src set on first activation). -->
|
||||
<iframe
|
||||
v-else
|
||||
class="dms-frame"
|
||||
:src="t.loaded ? t.src : 'about:blank'"
|
||||
:title="t.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</section>
|
||||
</VerticalTabsLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
|
||||
// The Driver's section/folder nav model, ported from app-wrapper-material/nav.js (the verified
|
||||
// source of the exact sections the Driver wants). Each entry is a GROUP for VerticalTabsLayout; each
|
||||
// `tab` names a REAL TAB_DEFS id so clicking a row still drives the SAME mount machinery (onSelect →
|
||||
// findIndex(t.id === key)). Sections/folders + icons are authored here; rows whose bundle does not
|
||||
// exist in this SPA are OMITTED (never paint a dead nav row — nav.js's own navForTarget prunes empty
|
||||
// groups the same way). `folder:true` (Experimental) renders collapsible, starting closed.
|
||||
const NAV_MODEL = [
|
||||
// Dashboards — Big Picture stays the FIRST/default tab (its dashboard→grid coupling is the landing
|
||||
// surface). Matches nav.js's `dashboards` section; kept top so tab-1 remains Big Picture.
|
||||
{ key: 'dashboards', label: 'Dashboards', icon: 'monitor',
|
||||
tabs: [ { id: 'big-picture', icon: 'chart' } ] },
|
||||
// Prayer Mobilization FIRST among the map sections (the prayer map is the mobilisation landing).
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
|
||||
tabs: [
|
||||
{ id: 'prayer-map', icon: 'embed' }, // Prayer Progress = the world prayer map
|
||||
{ id: 'upg-100', icon: 'list' }, // My UPG 100 List
|
||||
] },
|
||||
// Research — a peer section (nav.js: "a section to the same degree as Laborer").
|
||||
{ key: 'research', label: 'Research', icon: 'search',
|
||||
tabs: [
|
||||
{ id: 'research-map', icon: 'map' },
|
||||
{ id: 'cities-internet', icon: 'wifi' },
|
||||
{ id: 'gospel-resources', icon: 'book' },
|
||||
] },
|
||||
// Experimental — a collapsible FOLDER at the bottom (nav.js: kind 'folder', open:false). Holds the
|
||||
// real lab maps (built bundles with their own leaf pages). Starts CLOSED; click the heading to open.
|
||||
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false,
|
||||
tabs: [
|
||||
{ id: 'all-people-groups', icon: 'globe' },
|
||||
{ id: 'country-posters', icon: 'poster', badge: 'lab' },
|
||||
] },
|
||||
]
|
||||
|
||||
// The tab manifest. Each entry names a real, built bundle in this tree and how to nest it.
|
||||
// mode 'element' → inject <bundleJs> once, then create <tag> (direct-JS; token-free maps only).
|
||||
// mode 'iframe' → point an iframe at the bundle's standalone leaf page (its own SPA).
|
||||
// leaf/bundleJs are TREE-RELATIVE (resolved against treeBase at runtime), matching the manifest's
|
||||
// iframeUrl/scriptUrl shape ("./<bundle>/<profile>/index.html", "./<bundle>/<bundle>.js").
|
||||
const TAB_DEFS = [
|
||||
{
|
||||
id: 'big-picture', label: 'Big Picture', sub: 'search + people groups', emoji: '🌍',
|
||||
mode: 'element', tag: 'bp-big-picture',
|
||||
bundleJs: 'big-picture/big-picture.js',
|
||||
leaf: 'big-picture/big-picture/index.html'
|
||||
},
|
||||
{
|
||||
id: 'research-map', label: 'Research Map', sub: 'prayer · engagement · regions', emoji: '🗺️',
|
||||
mode: 'iframe', tag: 'doxa-research-map',
|
||||
leaf: 'doxa-research-map/research-map/index.html'
|
||||
},
|
||||
{
|
||||
id: 'prayer-map', label: 'Prayer Map', sub: 'simple world prayer map', emoji: '🙏',
|
||||
mode: 'iframe', tag: 'doxa-simple-map',
|
||||
// `prayer` is an INSTANCE (not a profile), so its built leaf lives one level deeper under the
|
||||
// bundle dir: doxa-simple-map/doxa-simple-map/prayer/ (see manifest.json). The shallow path 404s.
|
||||
leaf: 'doxa-simple-map/doxa-simple-map/prayer/index.html'
|
||||
},
|
||||
{
|
||||
id: 'cities-internet', label: 'Cities & Internet', sub: 'connectivity of key cities', emoji: '📡',
|
||||
mode: 'iframe', tag: 'cities-internet-map',
|
||||
leaf: 'cities-internet/connectivity-map/index.html'
|
||||
},
|
||||
{
|
||||
id: 'gospel-resources', label: 'Gospel Resources', sub: 'resources by place', emoji: '📖',
|
||||
mode: 'iframe', tag: 'gospel-resources-map',
|
||||
leaf: 'gospel-resources/resources-map/index.html'
|
||||
},
|
||||
{
|
||||
id: 'upg-100', label: 'UPG-100 List', sub: 'my unreached list + posters', emoji: '📋',
|
||||
mode: 'iframe', tag: 'my-upg-100-list-map',
|
||||
leaf: 'my-upg-100-list/upg-100-mfe/index.html'
|
||||
},
|
||||
// ── Experimental lab maps (real, built bundles with standalone leaf pages) — surfaced under the
|
||||
// collapsible Experimental folder. Kept lazy iframes like the other maps.
|
||||
{
|
||||
id: 'all-people-groups', label: 'All People Groups', sub: 'the full global set (~15k)', emoji: '🌐',
|
||||
mode: 'iframe', tag: 'all-people-groups',
|
||||
leaf: 'all-people-groups/all-people-groups/index.html'
|
||||
},
|
||||
{
|
||||
id: 'country-posters', label: 'Country Posters', sub: 'printable country prayer sheets', emoji: '🖼️',
|
||||
mode: 'iframe', tag: 'country-posters-map',
|
||||
leaf: 'country-posters/countries-map/index.html'
|
||||
}
|
||||
]
|
||||
|
||||
// Load a script exactly once per URL (mirrors the gallery's once()). Resolves when the IIFE has
|
||||
// executed (so its customElements.define has run). SRI is optional (attrs.integrity).
|
||||
const _loaded = {}
|
||||
function loadScriptOnce(url, attrs) {
|
||||
if (_loaded[url]) return _loaded[url]
|
||||
_loaded[url] = new Promise((ok, fail) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = url
|
||||
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
|
||||
s.onload = () => ok()
|
||||
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return _loaded[url]
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DoxaMapsSpa',
|
||||
components: { VerticalTabsLayout },
|
||||
// profile-config props the standalone leaf page + gallery hand every element. treeBase lets a
|
||||
// host pin the tree root explicitly; the rest are accepted so the element is a well-behaved
|
||||
// member of the bundler (profile/instanceId/assetsBase/tk/shareUrl/qa are ignored here — the
|
||||
// app-shell itself paints no map, its tabs do).
|
||||
props: {
|
||||
treeBase: { type: String, default: '' },
|
||||
profileConfig: { type: [String, Object], default: '' },
|
||||
title: { type: String, default: 'DX-Maps' },
|
||||
// Optional: pin the Mapbox token URL passed down to iframe leaf pages via ?tokenUrl / ?tk.
|
||||
tokenUrl: { type: String, default: '' },
|
||||
tk: { type: String, default: '' }
|
||||
},
|
||||
setup() {
|
||||
// index.js provides the tree base it derived from the running <script> URL. Prop/attr override
|
||||
// it if present (resolved in `base` below).
|
||||
const injectedBase = inject('dmsTreeBase', '')
|
||||
return { injectedBase }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeIndex: 0,
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
localeOpen: false,
|
||||
switcherLangs: SWITCHER_LANGUAGES,
|
||||
tabs: TAB_DEFS.map(t => ({
|
||||
...t,
|
||||
loaded: false, // iframe: has src been set yet
|
||||
mounted: false, // element: has the tag been created yet
|
||||
state: 'idle', // element: idle | loading | ready | error
|
||||
error: '',
|
||||
src: '' // iframe: resolved absolute leaf URL
|
||||
}))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Merge profile-config (string or object) over the discrete props, so a host can pass either.
|
||||
cfg() {
|
||||
let pc = this.profileConfig
|
||||
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
|
||||
return pc && typeof pc === 'object' ? pc : {}
|
||||
},
|
||||
// The tree root URL, with a trailing slash. Precedence:
|
||||
// 1. treeBase prop / profile-config.treeBase (host override)
|
||||
// 2. the base index.js derived from the running <script> URL
|
||||
// 3. a same-tree relative guess ('') so a co-located drop still resolves.
|
||||
base() {
|
||||
const raw = (this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
|
||||
if (!raw) return '' // relative: leaf pages sit beside this bundle's folder
|
||||
return raw.replace(/\/+$/, '') + '/'
|
||||
},
|
||||
// The GROUPED rail model the shared VerticalTabsLayout paints, built from NAV_MODEL (the Driver's
|
||||
// ported sections/folder) projected over the REAL tabs. Each row's `key` = the tab id (stable),
|
||||
// which onSelect maps back to the tab index the mount machinery uses — so the sections are pure
|
||||
// presentation, the mount path is unchanged. Rows whose tab id has no matching TAB_DEF are
|
||||
// dropped, and a group with no surviving rows is omitted (never paint a dead section/row).
|
||||
navGroups() {
|
||||
const byId = Object.fromEntries(this.tabs.map(t => [t.id, t]))
|
||||
return NAV_MODEL
|
||||
.map(g => {
|
||||
const rows = (g.tabs || [])
|
||||
.filter(r => byId[r.id])
|
||||
.map(r => {
|
||||
const t = byId[r.id]
|
||||
return { key: t.id, label: t.label, sub: t.sub, icon: r.icon, badge: r.badge }
|
||||
})
|
||||
return { key: g.key, label: g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
|
||||
})
|
||||
.filter(g => g.rows.length)
|
||||
},
|
||||
// The active language's flag/native name for the toolbar toggle button.
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
|
||||
activeKey() {
|
||||
const t = this.tabs[this.activeIndex]
|
||||
return t ? t.id : ''
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
|
||||
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
|
||||
this.select(0)
|
||||
},
|
||||
methods: {
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
// Switch UI language. languages.js's contract: switching = re-setting profile-config.locale on
|
||||
// the mounted element (ProfileLoader reads it, drives the bundle's i18n). We persist the choice
|
||||
// and best-effort re-set the locale on tab-1's mounted <bp-big-picture> (the only direct element;
|
||||
// the iframe maps carry their own locale ladder). No router, no dependency.
|
||||
setLocale(code) {
|
||||
this.locale = code
|
||||
this.localeOpen = false
|
||||
saveLocale(code)
|
||||
try {
|
||||
const slot = this._slots && this._slots[0]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute) {
|
||||
const pc = { profile: 'big-picture', instanceId: 'dms-big-picture', locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
||||
},
|
||||
// The shared rail emits the selected tab's `key` (its id); map it back to the index the
|
||||
// pane-mount machinery below is keyed on.
|
||||
onSelect(key) {
|
||||
const i = this.tabs.findIndex(t => t.id === key)
|
||||
if (i !== -1) this.select(i)
|
||||
},
|
||||
// Resolve a tree-relative ref ("./x" / "x") against the tree base. If base is '' we keep it
|
||||
// relative to THIS document — but this bundle's own folder is <base>/doxa-maps-spa/, so a
|
||||
// co-located leaf at <base>/big-picture/... is one level UP: prefix '../'.
|
||||
resolve(ref) {
|
||||
const r = String(ref).replace(/^\.\//, '')
|
||||
if (this.base) return this.base + r
|
||||
return '../' + r // relative fallback: sibling bundle folders are one level up
|
||||
},
|
||||
select(i) {
|
||||
this.activeIndex = i
|
||||
const t = this.tabs[i]
|
||||
if (!t) return
|
||||
if (t.mode === 'iframe') {
|
||||
if (!t.loaded) { t.src = this.withToken(this.resolve(t.leaf)); t.loaded = true }
|
||||
} else if (t.mode === 'element') {
|
||||
if (!t.mounted) this.mountElement(i)
|
||||
}
|
||||
},
|
||||
// Pass the host's token URL / token down to the iframe leaf page so a map paints without the
|
||||
// user pasting a token, exactly as the gallery forwards ?tk. Both are OPTIONAL — the leaf page
|
||||
// has its own full token ladder if we pass nothing.
|
||||
withToken(url) {
|
||||
const tokenUrl = (this.tokenUrl || this.cfg.tokenUrl || '').trim()
|
||||
const tk = (this.tk || this.cfg.tk || '').trim()
|
||||
const q = []
|
||||
if (tk) q.push('tk=' + encodeURIComponent(tk))
|
||||
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
|
||||
if (!q.length) return url
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
|
||||
},
|
||||
// TAB-1 direct-JS mount: inject the composite bundle's IIFE once, then create the real tag.
|
||||
mountElement(i) {
|
||||
const t = this.tabs[i]
|
||||
t.mounted = true
|
||||
t.state = 'loading'
|
||||
const jsUrl = this.resolve(t.bundleJs)
|
||||
loadScriptOnce(jsUrl).then(() => {
|
||||
// The IIFE has run; the tag is now defined by that bundle (we never define it ourselves).
|
||||
const slot = this._slots && this._slots[i]
|
||||
if (!slot) { t.state = 'error'; t.error = 'no slot'; return }
|
||||
// Remove the loading hint, insert the real element with its profile-config.
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(t.tag)
|
||||
const pc = { profile: t.id, instanceId: 'dms-' + t.id }
|
||||
// Big Picture is token-free; only forward a token if the host gave one.
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
||||
slot.appendChild(el)
|
||||
t.state = 'ready'
|
||||
}).catch((e) => {
|
||||
t.state = 'error'
|
||||
t.error = (e && e.message) || String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* The shell (top bar + rail + content frame) is the shared VerticalTabsLayout. Only the PANE-local
|
||||
styles for this SPA's own tab content remain here — panes fill the shell's content slot, and the
|
||||
direct-JS slot / lazy iframe / loading hint each fill their pane. */
|
||||
.dms-pane { position: absolute; inset: 0; }
|
||||
.dms-slot { position: absolute; inset: 0; }
|
||||
.dms-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #0b0f14; }
|
||||
.dms-hint {
|
||||
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
|
||||
color: #94a3b8; font-size: 13px; padding: 20px;
|
||||
}
|
||||
.dms-hint.err { color: #f87171; }
|
||||
.dms-hint small { color: #64748b; }
|
||||
|
||||
/* #brand logo mark (left of the shell title). */
|
||||
.dms-logo { display: inline-flex; align-items: center; }
|
||||
.dms-logo svg { display: block; }
|
||||
|
||||
/* #toolbar i18n locale switcher — matches the admin shell tokens (neutral surfaces, green accent). */
|
||||
.dms-locale { position: relative; padding: 8px 12px 0; display: flex; justify-content: flex-end; }
|
||||
.dms-locale-btn {
|
||||
display: inline-flex; align-items: center; gap: 8px; font: inherit; font-size: 12px;
|
||||
padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626;
|
||||
color: #f5f5f5; cursor: pointer;
|
||||
}
|
||||
.dms-locale-btn:hover { background: #404040; }
|
||||
.dms-flag { font-size: 14px; line-height: 1; }
|
||||
.dms-locale-name { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dms-dots { color: #a3a3a3; margin-left: 2px; }
|
||||
.dms-locale-menu {
|
||||
position: absolute; top: 100%; right: 12px; margin-top: 4px; z-index: 30; min-width: 180px;
|
||||
list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px;
|
||||
background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto;
|
||||
}
|
||||
.dms-locale-opt {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit;
|
||||
font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none;
|
||||
color: #d4d4d4; cursor: pointer;
|
||||
}
|
||||
.dms-locale-opt:hover { background: #404040; color: #f5f5f5; }
|
||||
.dms-locale-opt.active { background: #404040; color: #f5f5f5; }
|
||||
.dms-locale-opt.active .dms-locale-name { color: #00DC82; }
|
||||
.dms-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
|
||||
</style>
|
||||
45
plugin-designs/doxa-maps-spa/index.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · doxa-maps-spa staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Capability-5 app-shell (nested-mfe-spa): ONE element, <doxa-maps-spa>, is a vertical-tab
|
||||
layout whose tab-1 is the Big Picture (mounted by its real <bp-big-picture> tag + built .js)
|
||||
and whose other tabs are every research map, each nested as its own SPA (leaf-page iframe).
|
||||
No Mapbox peer is loaded HERE — each map tab's leaf page loads its own peer + token ladder. -->
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #0b0f14; color: #e5e7eb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
.wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||
.intro { flex: 0 0 auto; padding: 14px 18px; border-bottom: 1px solid #1f2937; }
|
||||
.intro h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.intro p { color: #9ca3af; font-size: 12.5px; margin-top: 5px; max-width: 900px; }
|
||||
.intro code { color: #c4b5fd; }
|
||||
.frame { flex: 1 1 auto; position: relative; min-height: 0; }
|
||||
doxa-maps-spa { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="intro">
|
||||
<h1>doxa-maps-spa · staging</h1>
|
||||
<p>Capability 5 — the <strong>app-shell</strong>. One element,
|
||||
<code><doxa-maps-spa></code>, is a vertical-tab layout of the whole DX-maps app:
|
||||
<strong>tab 1 = Big Picture</strong> (its real <code><bp-big-picture></code>
|
||||
header + people-groups grid, mounted from the built <code>big-picture.js</code>), and the
|
||||
other tabs are every production research map, each nested as its own full SPA. Switch tabs
|
||||
with no page reload; each map tab loads lazily on first open.</p>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<doxa-maps-spa id="dms" title="DX-Maps"></doxa-maps-spa>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vite multi-page entry — emits the IIFE that registers <doxa-maps-spa>. -->
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
plugin-designs/doxa-maps-spa/index.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* doxa-maps-spa / index.js — Bundle entry (Capability-5 nested-mfe-spa / app-shell).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> ONE custom element: <doxa-maps-spa>.
|
||||
*
|
||||
* <doxa-maps-spa> is the OUTER app-shell of the whole DX-maps app: a vertical-tab layout whose
|
||||
* tab-1 is the Cat-4 Big Picture (mounted directly via its real <bp-big-picture> tag + the built
|
||||
* big-picture.js) and whose other tabs are every production research map, each nested as its OWN
|
||||
* full SPA via that map's standalone leaf page (iframe). See doxa-maps-spa.vue for the full why.
|
||||
*
|
||||
* ★ ONE-SHOT-DEFINE SAFETY: the ONLY tag this bundle registers is <doxa-maps-spa>. It NEVER
|
||||
* imports another plugin's components and NEVER re-registers a tag another bundle owns. The
|
||||
* nested maps register their own tags when THEIR IIFE loads (tab-1 loads big-picture.js at
|
||||
* runtime; the iframe tabs run each map's own document). So loading this bundle beside any other
|
||||
* bundle can never collide.
|
||||
*
|
||||
* ★ TREE BASE: to find each nested map's .js / leaf page, the shell needs the built tree's root
|
||||
* URL. We derive it HERE, at IIFE load, from this script's own URL
|
||||
* (.../doxa-maps/doxa-maps-spa/doxa-maps-spa.js) → two levels up = the tree root. That value is
|
||||
* provided to the Vue app so the element self-locates from wherever the tree is served (any
|
||||
* static host, a subpath, file://). A host can still override it with the tree-base="" attribute
|
||||
* or profile-config.treeBase.
|
||||
*
|
||||
* Usage from a host page (direct-JS, deploy-method-3):
|
||||
* <script src="/doxa-maps/doxa-maps-spa/doxa-maps-spa.js"></script>
|
||||
* <doxa-maps-spa></doxa-maps-spa>
|
||||
* <!-- or pin the tree explicitly: -->
|
||||
* <doxa-maps-spa tree-base="https://your-host/doxa-maps"></doxa-maps-spa>
|
||||
*/
|
||||
|
||||
import { defineCustomElement } from 'vue'
|
||||
import DoxaMapsSpa from './doxa-maps-spa.vue'
|
||||
|
||||
// Capture the running <script>'s own URL synchronously at IIFE load, BEFORE any async runs, so
|
||||
// document.currentScript is still this bundle's tag. Derive the tree root = two levels up
|
||||
// (.../doxa-maps/doxa-maps-spa/doxa-maps-spa.js → .../doxa-maps/). Guarded for non-browser.
|
||||
function deriveTreeBase() {
|
||||
try {
|
||||
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
|
||||
if (!cur) return ''
|
||||
// Strip the trailing "<bundle>/<bundle>.js" → the tree root, with trailing slash.
|
||||
const root = cur.replace(/[^/]+\/[^/]+$/, '')
|
||||
return root.replace(/\/+$/, '') + '/'
|
||||
} catch { return '' }
|
||||
}
|
||||
const TREE_BASE = deriveTreeBase()
|
||||
|
||||
const DoxaMapsSpaElement = defineCustomElement(DoxaMapsSpa, {
|
||||
configureApp(app) {
|
||||
// Hand the derived tree root to the component (props/attrs still override it).
|
||||
app.provide('dmsTreeBase', TREE_BASE)
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('doxa-maps-spa')) {
|
||||
customElements.define('doxa-maps-spa', DoxaMapsSpaElement)
|
||||
}
|
||||
|
||||
export default DoxaMapsSpaElement
|
||||
|
|
@ -490,7 +490,7 @@ const geocoderPlaceholder = computed(() => {
|
|||
// Search context (Round 16) — maps the active legend tab to the category the
|
||||
// unified search bar should surface FIRST. The three people-centric color tabs
|
||||
// (prayer / adoption / engagement) share the people-groups context since they
|
||||
// all paint people-group pins. null → useDoxaSearch uses its default order.
|
||||
// all paint people-group pins. null → useEntitySearch uses its default order.
|
||||
const TAB_SEARCH_CONTEXT = {
|
||||
'affinity-blocks': 'people-groups',
|
||||
'prayer': 'people-groups',
|
||||
|
|
|
|||
|
|
@ -667,7 +667,7 @@ function handlePeopleGroupResult(feature) {
|
|||
uiStore.setLegendState('open')
|
||||
}
|
||||
if (!uiStore.isMobile) {
|
||||
window.dispatchEvent(new CustomEvent('doxa:openDesktopLegend', { detail: { mapId } }))
|
||||
window.dispatchEvent(new CustomEvent('dxm:openDesktopLegend', { detail: { mapId } }))
|
||||
}
|
||||
// Highlight the matching pin on the map (ACTIVE_LAYER filter)
|
||||
try {
|
||||
|
|
@ -1362,7 +1362,7 @@ onBeforeUnmount(() => {
|
|||
<!-- Geocoder search bar — added to top-left first so hamburger floats right of it.
|
||||
Supplements Mapbox's place results with DOXA people-group / country /
|
||||
language / religion matches via the localGeocoder option (see
|
||||
GeocoderComponent + useDoxaSearch composable). When the user picks
|
||||
GeocoderComponent + useEntitySearch composable). When the user picks
|
||||
a people-group result we open the detail popup in addition to the
|
||||
default flyTo behavior. -->
|
||||
<GeocoderComponent
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the vision-card element. provider:'maplibre' flags the generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so it mounts the element INSTANTLY with no Mapbox token ladder — correct for a self-contained single element that uses no map at all.",
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"title": "Vision card — reach at a glance",
|
||||
"props": { "profile": "doxa-vision-card", "provider": "maplibre", "instanceId": "vision-card-default" }
|
||||
},
|
||||
{
|
||||
"id": "frontier",
|
||||
"title": "Vision card — frontier accent",
|
||||
"props": {
|
||||
"profile": "doxa-vision-card",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "vision-card-frontier",
|
||||
"accent": "#22d3ee",
|
||||
"title": "The last frontier of the gospel.",
|
||||
"tagline": "Frontier people groups — where fewer than one in a thousand follow Jesus.",
|
||||
"stats": [
|
||||
{ "value": "1,910", "label": "Frontier groups", "note": "under 0.1% believers" },
|
||||
{ "value": "2.1B", "label": "People", "note": "living in frontier settings" },
|
||||
{ "value": "0.1%", "label": "Christian", "note": "or fewer, in each group" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
241
plugin-designs/doxa-vision-card/doxa-vision-card.vue
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<script setup>
|
||||
/**
|
||||
* doxa-vision-card.vue — Application Profile: Vision Card (Capability-1 showcase)
|
||||
*
|
||||
* @description The Capability-1 example: ONE folder -> ONE bundle -> ONE element.
|
||||
* A single, self-contained, genuinely beautiful info element — a
|
||||
* "mission vision card" that rotates through a small set of reach
|
||||
* stats over an animated aurora backdrop. NO map, NO Mapbox token,
|
||||
* NO library map-infra: it renders INSTANTLY on any page, embedded
|
||||
* via iframe OR direct-JS, which is exactly what the lightest
|
||||
* capability tier is meant to demonstrate.
|
||||
* @profile doxa-vision-card
|
||||
* @bundle doxa-vision-card
|
||||
* @element doxa-vision-card
|
||||
*
|
||||
* This is a PURE single element: it is registered directly with
|
||||
* defineCustomElement (see index.js) rather than through the map ProfileLoader,
|
||||
* so it carries no nested SPA, no router and no remote data dependency. It reads
|
||||
* the standard `profile-config` attribute only to accept optional overrides
|
||||
* (title, tagline, custom stat cards, accent color) — with sensible defaults it
|
||||
* needs nothing at all.
|
||||
*/
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
// ─── profile-config prop (optional overrides) ────────────────────────────────
|
||||
// defineCustomElement passes HTML attributes as props. We accept the same
|
||||
// `profile-config` JSON attribute the rest of the bundler uses, so the studio's
|
||||
// generated pages (which stamp a profile-config) render us with no extra wiring.
|
||||
const props = defineProps({
|
||||
profileConfig: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
if (!props.profileConfig) return {}
|
||||
try { return JSON.parse(props.profileConfig) || {} }
|
||||
catch { return {} }
|
||||
})
|
||||
|
||||
const accent = computed(() => config.value.accent || '#8b5cf6')
|
||||
const heading = computed(() => config.value.title || 'Every people. Every place.')
|
||||
const tagline = computed(() => config.value.tagline || 'A living picture of the unreached — one light for every group still waiting.')
|
||||
|
||||
// Default rotating stats. A host may override with profile-config.stats:
|
||||
// [{ value, label, note }]
|
||||
const DEFAULT_STATS = [
|
||||
{ value: '7,398', label: 'People groups', note: 'catalogued worldwide' },
|
||||
{ value: '3,240', label: 'Still unreached', note: 'no indigenous church movement' },
|
||||
{ value: '42%', label: 'Of the world', note: 'lives beyond a gospel witness' },
|
||||
{ value: '1,910', label: 'Frontier groups', note: 'under 0.1% believers' },
|
||||
]
|
||||
const stats = computed(() => {
|
||||
const s = config.value.stats
|
||||
return Array.isArray(s) && s.length ? s : DEFAULT_STATS
|
||||
})
|
||||
|
||||
// ─── Rotation ────────────────────────────────────────────────────────────────
|
||||
const idx = ref(0)
|
||||
const active = computed(() => stats.value[idx.value % stats.value.length])
|
||||
let timer = null
|
||||
|
||||
function go(i) {
|
||||
idx.value = ((i % stats.value.length) + stats.value.length) % stats.value.length
|
||||
restart()
|
||||
}
|
||||
function next() { idx.value = (idx.value + 1) % stats.value.length }
|
||||
function restart() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(next, 4200)
|
||||
}
|
||||
|
||||
onMounted(restart)
|
||||
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vc-root" :style="{ '--accent': accent }">
|
||||
<!-- Animated aurora backdrop -->
|
||||
<div class="vc-aurora" aria-hidden="true">
|
||||
<span class="vc-blob vc-blob-a"></span>
|
||||
<span class="vc-blob vc-blob-b"></span>
|
||||
<span class="vc-blob vc-blob-c"></span>
|
||||
</div>
|
||||
<div class="vc-grain" aria-hidden="true"></div>
|
||||
|
||||
<div class="vc-content">
|
||||
<div class="vc-kicker">
|
||||
<span class="vc-pulse"></span>
|
||||
Doxa · Vision
|
||||
</div>
|
||||
|
||||
<h1 class="vc-title">{{ heading }}</h1>
|
||||
<p class="vc-tagline">{{ tagline }}</p>
|
||||
|
||||
<!-- The rotating stat -->
|
||||
<div class="vc-stat" :key="idx">
|
||||
<div class="vc-stat-value">{{ active.value }}</div>
|
||||
<div class="vc-stat-label">{{ active.label }}</div>
|
||||
<div v-if="active.note" class="vc-stat-note">{{ active.note }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="vc-dots" role="tablist" aria-label="Vision stats">
|
||||
<button
|
||||
v-for="(s, i) in stats"
|
||||
:key="i"
|
||||
class="vc-dot"
|
||||
:class="{ 'is-active': i === idx }"
|
||||
type="button"
|
||||
:aria-label="s.label"
|
||||
@click="go(i)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* defineCustomElement injects this root <style> into the element's shadow DOM. */
|
||||
.vc-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(140% 120% at 20% 0%, #141a2e 0%, #0a0e18 55%, #05070d 100%);
|
||||
color: #eef2fb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ── Aurora blobs ── */
|
||||
.vc-aurora { position: absolute; inset: -20%; z-index: 0; filter: blur(60px); opacity: .85; }
|
||||
.vc-blob { position: absolute; width: 46%; height: 46%; border-radius: 50%; mix-blend-mode: screen; }
|
||||
.vc-blob-a { background: var(--accent); top: -6%; left: 8%; animation: vc-drift-a 16s ease-in-out infinite; }
|
||||
.vc-blob-b { background: #22d3ee; bottom: -10%; right: 4%; animation: vc-drift-b 20s ease-in-out infinite; }
|
||||
.vc-blob-c { background: #f472b6; top: 30%; right: 30%; width: 34%; height: 34%; animation: vc-drift-c 24s ease-in-out infinite; }
|
||||
|
||||
@keyframes vc-drift-a { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(8%, 10%) scale(1.15); } }
|
||||
@keyframes vc-drift-b { 0%,100% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-10%,-8%) scale(.9); } }
|
||||
@keyframes vc-drift-c { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(-6%, 12%) scale(1.2); } }
|
||||
|
||||
/* Subtle grain / vignette so text stays crisp over the aurora */
|
||||
.vc-grain {
|
||||
position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(120% 100% at 50% 40%, transparent 40%, rgba(3,5,10,.55) 100%);
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.vc-content {
|
||||
position: relative; z-index: 2;
|
||||
width: min(88%, 560px);
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
animation: vc-rise .7s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
@keyframes vc-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.vc-kicker {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
|
||||
color: #c7d2fe;
|
||||
padding: 6px 14px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.vc-pulse {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 var(--accent);
|
||||
animation: vc-pulse 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes vc-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); }
|
||||
70% { box-shadow: 0 0 0 10px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
|
||||
.vc-title {
|
||||
margin: 20px 0 10px;
|
||||
font-size: clamp(24px, 5.2vw, 40px);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #c7cde6 100%);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent; color: transparent;
|
||||
}
|
||||
.vc-tagline {
|
||||
margin: 0 auto 26px;
|
||||
max-width: 46ch;
|
||||
font-size: clamp(13px, 2vw, 15px);
|
||||
line-height: 1.6;
|
||||
color: #aab4d4;
|
||||
}
|
||||
|
||||
/* ── Rotating stat ── */
|
||||
.vc-stat {
|
||||
margin: 0 auto 22px;
|
||||
padding: 22px 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
box-shadow: 0 20px 50px -24px rgba(0,0,0,.7);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: vc-swap .5s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
@keyframes vc-swap { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: none; } }
|
||||
.vc-stat-value {
|
||||
font-size: clamp(38px, 9vw, 64px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 32px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.vc-stat-label {
|
||||
margin-top: 10px;
|
||||
font-size: 14px; font-weight: 700; letter-spacing: .04em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.vc-stat-note { margin-top: 4px; font-size: 12px; color: #8b97b8; }
|
||||
|
||||
/* ── Dots ── */
|
||||
.vc-dots { display: flex; gap: 8px; justify-content: center; }
|
||||
.vc-dot {
|
||||
width: 8px; height: 8px; padding: 0; border: none; cursor: pointer;
|
||||
border-radius: 999px; background: rgba(255,255,255,.22);
|
||||
transition: background .2s, width .2s, transform .2s;
|
||||
}
|
||||
.vc-dot:hover { transform: scale(1.25); background: rgba(255,255,255,.4); }
|
||||
.vc-dot.is-active { width: 22px; background: var(--accent); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vc-blob, .vc-content, .vc-stat, .vc-pulse { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
44
plugin-designs/doxa-vision-card/index.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · doxa-vision-card staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Capability-1 single element: NO Mapbox / MapLibre peer needed. The element
|
||||
is fully self-contained (no map, no token) and renders instantly. -->
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { background: #0b0f14; color: #e5e7eb; 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: #9ca3af; font-size: 13px; margin: 6px 0 24px; }
|
||||
.slot { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
.slot .label { font: 12px/1 ui-monospace, monospace; color: #9ca3af; padding: 8px 12px;
|
||||
background: #111827; border-bottom: 1px solid #1f2937; }
|
||||
.frame { position: relative; height: 70vh; min-height: 420px; }
|
||||
doxa-vision-card { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>doxa-vision-card · staging</h1>
|
||||
<p class="sub">Capability 1 — one folder → one bundle → one element. A single,
|
||||
self-contained vision card: no map, no token, renders instantly. Embeds via
|
||||
<code><iframe></code> or a direct <code><script></code> + custom-element tag.</p>
|
||||
|
||||
<div class="slot">
|
||||
<div class="label"><doxa-vision-card> — single custom element</div>
|
||||
<div class="frame">
|
||||
<doxa-vision-card id="vision-card-slot"></doxa-vision-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vite multi-page entry. In dev, served transformed; in build, Vite emits
|
||||
the doxa-vision-card IIFE and rewrites this script tag. -->
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
plugin-designs/doxa-vision-card/index.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* plugin-designs/doxa-vision-card/index.js
|
||||
*
|
||||
* Bundle entry — registers the <doxa-vision-card> web component.
|
||||
*
|
||||
* Capability-1 (single element) showcase: one folder -> one bundle -> one
|
||||
* element. Unlike the map bundles (demo-map, doxa-simple-map, …) this does NOT
|
||||
* go through @map/ProfileLoader.vue — there is nothing to load, no nested SPA,
|
||||
* no map, no token. The element IS the root component, wrapped directly with
|
||||
* Vue's defineCustomElement, so the built IIFE is tiny and renders instantly
|
||||
* anywhere HTML+JS runs (iframe embed or direct-JS embed).
|
||||
*
|
||||
* Folder name = bundle name. This folder builds to exactly one output file:
|
||||
* public/doxa-maps/doxa-vision-card/doxa-vision-card.js
|
||||
*
|
||||
* Usage from a host page:
|
||||
* <script src=".../doxa-vision-card/doxa-vision-card.js"></script>
|
||||
* <doxa-vision-card></doxa-vision-card>
|
||||
* <!-- optional overrides -->
|
||||
* <doxa-vision-card profile-config='{"accent":"#22d3ee","title":"…"}'></doxa-vision-card>
|
||||
*/
|
||||
|
||||
import { defineCustomElement } from 'vue'
|
||||
import VisionCard from './doxa-vision-card.vue'
|
||||
|
||||
// Root SFC -> custom element. Vue injects the root component's <style> block into
|
||||
// the element's shadow DOM automatically, so no shadow-style helper is needed.
|
||||
const VisionCardElement = defineCustomElement(VisionCard)
|
||||
|
||||
// One tag per bundle, named after the bundle. The guard prevents an
|
||||
// "already registered" error if the IIFE is ever loaded twice on one page.
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('doxa-vision-card')) {
|
||||
customElements.define('doxa-vision-card', VisionCardElement)
|
||||
}
|
||||
|
||||
export default VisionCardElement
|
||||
|
|
@ -236,7 +236,7 @@ const geocoderRef = ref(null) // <GeocoderComponent> exposed ref
|
|||
// bar), so its context is constant.
|
||||
//
|
||||
// 'people-groups' orders the result groups blocs → clusters → peopleGroups first
|
||||
// (useDoxaSearch.js ORDER_BY_CONTEXT), which matches this list's zone/territory/PG
|
||||
// (useSearch.js ORDER_BY_CONTEXT), which matches this list's zone/territory/PG
|
||||
// model. Without it the prop defaulted to '' → ALL_GROUPS ordering, i.e. the
|
||||
// generic list rather than this map's own — the drift the rebuild spec flagged.
|
||||
// The legend names the MAP, not just the taxonomy — with several maps in one app
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
* whole MFE (both panes) is what gets shared/embedded.
|
||||
*/
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '../../layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
||||
|
|
|
|||
80
plugin-designs/people-groups-grid/index.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · people-groups-grid staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { background: #f1f5f9; color: #0f172a; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 28px 20px 48px; }
|
||||
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sub { color: #475569; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
|
||||
.sub code { color: #2563eb; }
|
||||
h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #64748b; margin: 30px 0 10px; }
|
||||
.slot { border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; background: #fff; }
|
||||
.slot .label { font: 12px/1 ui-monospace, monospace; color: #64748b; padding: 8px 12px;
|
||||
background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
|
||||
.ext-bar { display: flex; gap: 8px; flex-wrap: wrap; padding: 10px 12px; background: #eef2ff; border-bottom: 1px solid #e2e8f0; }
|
||||
.ext-bar input { padding: 7px 10px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 13px; }
|
||||
.ext-bar .count { margin-left: auto; align-self: center; font-size: 13px; color: #2563eb; font-weight: 600; }
|
||||
people-groups-grid { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>people-groups-grid · staging</h1>
|
||||
<p class="sub">Capability 2 — a COPY of the DX-life people-groups grid
|
||||
(<code>UupgsList.vue</code>) migrated into an OWNED custom element
|
||||
<code><people-groups-grid></code>: fuzzy search + four dropdowns (Region / Block /
|
||||
People-Group / Religion) + faceted counts. Its own controls can be turned off
|
||||
(<code>show-search="false"</code> / <code>show-filters="false"</code>) so a parent header
|
||||
drives it via props; it emits <code>groups-filtered</code> both as a Vue event and a native
|
||||
bubbling CustomEvent. Data is fetched from the injected <code>pray-base-url</code>
|
||||
(default <code>https://pray.doxa.life</code>) — no Nuxt context required.</p>
|
||||
|
||||
<h2>1 · Standalone (owns its search + 4 dropdowns)</h2>
|
||||
<div class="slot">
|
||||
<div class="label"><people-groups-grid pray-base-url="https://pray.doxa.life"></div>
|
||||
<people-groups-grid id="standalone" pray-base-url="https://pray.doxa.life"></people-groups-grid>
|
||||
</div>
|
||||
|
||||
<h2>2 · Externally driven (controls off, host page filters it via props/events)</h2>
|
||||
<div class="slot">
|
||||
<div class="ext-bar">
|
||||
<input id="q" type="search" placeholder="host search → query prop">
|
||||
<input id="rel" type="text" placeholder="religion prop (e.g. Islam)">
|
||||
<span class="count" id="count">—</span>
|
||||
</div>
|
||||
<people-groups-grid id="driven" show-search="false" show-filters="false"
|
||||
pray-base-url="https://pray.doxa.life"></people-groups-grid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./index.js"></script>
|
||||
|
||||
<!-- Deploy-method-3 proof: a PLAIN host page drives the element through props + listens to
|
||||
its groups-filtered event. -->
|
||||
<script type="module">
|
||||
const grid = document.getElementById('driven')
|
||||
const q = document.getElementById('q')
|
||||
const rel = document.getElementById('rel')
|
||||
const count = document.getElementById('count')
|
||||
if (grid) {
|
||||
grid.addEventListener('groups-filtered', (e) => {
|
||||
const d = e.detail || {}
|
||||
count.textContent = (typeof d.total === 'number') ? `${d.total} groups` : '—'
|
||||
})
|
||||
q?.addEventListener('input', () => {
|
||||
q.value ? grid.setAttribute('query', q.value) : grid.removeAttribute('query')
|
||||
})
|
||||
rel?.addEventListener('input', () => {
|
||||
rel.value ? grid.setAttribute('religion', rel.value) : grid.removeAttribute('religion')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
plugin-designs/people-groups-grid/index.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* people-groups-grid / index.js — Bundle entry (Capability-2 prop-table / preset-element).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> ONE custom element: <people-groups-grid>.
|
||||
*
|
||||
* This is a COPY-ONLY migration of the DX-life people-groups grid (UupgsList.vue) into an
|
||||
* OWNED, self-contained custom element. It carries the Cat-2 contract:
|
||||
* - By default it owns its search box + the four filter dropdowns.
|
||||
* - show-search="false" / show-filters="false" turn those off so a parent header drives it
|
||||
* via the query / wagf-region / wagf-block / rop1 / religion props.
|
||||
* - It emits `groups-filtered` (Vue emit + native bubbling CustomEvent) so a host can react.
|
||||
*
|
||||
* Usage (direct-JS):
|
||||
* <script src=".../people-groups-grid/people-groups-grid.js"></script>
|
||||
* <people-groups-grid pray-base-url="https://pray.doxa.life"></people-groups-grid>
|
||||
*
|
||||
* Usage (externally driven, e.g. inside the Big-Picture Cat-4 shell):
|
||||
* <people-groups-grid id="grid" show-search="false" show-filters="false"></people-groups-grid>
|
||||
* <script>
|
||||
* grid.addEventListener('groups-filtered', e => console.log(e.detail.total))
|
||||
* grid.setAttribute('query', 'somali')
|
||||
* grid.setAttribute('religion', 'Islam')
|
||||
* </script>
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import PeopleGroupsGrid from './people-groups-grid.vue'
|
||||
|
||||
const PeopleGroupsGridElement = defineCustomElement(PeopleGroupsGrid)
|
||||
|
||||
if (typeof customElements !== 'undefined') {
|
||||
if (!customElements.get('people-groups-grid')) {
|
||||
customElements.define('people-groups-grid', PeopleGroupsGridElement)
|
||||
}
|
||||
}
|
||||
|
||||
export default PeopleGroupsGridElement
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the Cat-2 prop-table <people-groups-grid>. provider:'maplibre' flags each generated standalone page as TOKEN-FREE (no Mapbox token ladder) since this grid is a fetch-driven list, not a map. Each instance is one preset of the element's props: 'standalone' owns its own search + 4 dropdowns; 'driven' turns its own controls off (show-search/show-filters=false) to be driven by a parent header via props/events.",
|
||||
"instances": [
|
||||
{
|
||||
"id": "people-groups-grid",
|
||||
"title": "People Groups Grid — standalone (own search + 4 dropdowns)",
|
||||
"props": {
|
||||
"profile": "people-groups-grid",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "people-groups-grid",
|
||||
"pray-base-url": "https://pray.doxa.life"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "driven",
|
||||
"title": "People Groups Grid — externally driven (controls off, props/events)",
|
||||
"props": {
|
||||
"profile": "people-groups-grid",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "people-groups-grid-driven",
|
||||
"pray-base-url": "https://pray.doxa.life",
|
||||
"show-search": "false",
|
||||
"show-filters": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'preset-element' (machine key 'parameterized') = Capability-2: this bundle is a PROP-TABLE element that ships preconfigured instances (see people-groups-grid.instances.json) and is driven by props. It is a COPY-ONLY migration of the DX-life people-groups grid (UupgsList.vue) into an owned element, carrying the Cat-2 contract: show-search/show-filters turn its own controls off so a parent can drive it, and it emits groups-filtered. Filename must be <profile>.meta.json where profile = the root .vue name (people-groups-grid).",
|
||||
"type": "preset-element",
|
||||
"label": "People Groups Grid (Cat-2 prop-table: search + 4 dropdowns + faceted counts; show-search/show-filters + groups-filtered)"
|
||||
}
|
||||
570
plugin-designs/people-groups-grid/people-groups-grid.vue
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
<script setup>
|
||||
/**
|
||||
* people-groups-grid.vue — the Cat-2 (prop-table / preset-element) people-groups grid.
|
||||
*
|
||||
* WHAT THIS IS
|
||||
* A COPY-ONLY migration of the DX-life people-groups grid
|
||||
* (root-dx-life/research-map-clean/app/components/public/UupgsList.vue) into an OWNED,
|
||||
* self-contained doxa-maps custom element. The grid + Fuse-style search + the four
|
||||
* dropdowns + faceted (cross-filter) counts are copied from UupgsList.vue — NOT imported
|
||||
* from DX-life. DX-life is READ-ONLY source; nothing there is edited.
|
||||
*
|
||||
* THE CAT-2 CONTRACT (mirrors plugin-designs/big-picture/src/components/BpGroupsTable.vue)
|
||||
* - By DEFAULT it owns its internal search box + filter dropdowns and filters itself.
|
||||
* - `show-search` / `show-filters` ([Boolean,String], default true) TURN OFF its own
|
||||
* controls so an external header (e.g. the Big-Picture Cat-4 shell) can drive it.
|
||||
* - External filter props feed it when the matching control is hidden:
|
||||
* query, wagfRegion, wagfBlock, rop1 (people-group), religion
|
||||
* …the FOUR dropdown axes (Region / Block / People-Group / Religion). Country is the
|
||||
* 5th axis and is EXCLUDED here per Driver. Precedence = internal control (when shown)
|
||||
* ELSE the incoming prop (local-else-prop).
|
||||
* - Emits `groups-filtered` { query, activeFilters, total, slugs } on every change —
|
||||
* BOTH a Vue emit AND a native bubbling+composed DOM CustomEvent, so a plain host page
|
||||
* hears it when the element is embedded standalone.
|
||||
*
|
||||
* STANDALONE DEPS (injected, so it needs NO Nuxt context)
|
||||
* - prayBaseUrl : String prop (or profile-config.prayBaseUrl) — the people-groups API host
|
||||
* (default https://pray.doxa.life). The fetch is the SAME endpoint DX-life uses.
|
||||
* - languageCode: String prop — the `lang` query param.
|
||||
* - researchUrl / selectUrl : String props — link bases for the cards.
|
||||
* - t (i18n labels): via the `t` / profile-config.t object prop (no useI18n).
|
||||
*/
|
||||
import { ref, computed, watch, onMounted, getCurrentInstance } from 'vue'
|
||||
// SHARED search primitives (one source of truth for BOTH cap-2 grid + cap-1 dashboard).
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import { countrySlug } from './src/countries.js'
|
||||
|
||||
const props = defineProps({
|
||||
// ── injected standalone deps (replace Nuxt useRuntimeConfig / useI18n / useLocalePath) ──
|
||||
profileConfig: { type: String, default: '' },
|
||||
prayBaseUrl: { type: String, default: '' },
|
||||
languageCode: { type: String, default: '' },
|
||||
researchUrl: { type: String, default: '/research/' },
|
||||
selectUrl: { type: String, default: '' },
|
||||
t: { type: Object, default: () => ({}) },
|
||||
|
||||
// ── Cat-2 contract: turn the element's OWN controls off (accept string 'false' too) ──
|
||||
showSearch: { type: [Boolean, String], default: true },
|
||||
showFilters: { type: [Boolean, String], default: true },
|
||||
|
||||
// ── external filter props (used when the matching internal control is hidden) ──
|
||||
// THE FOUR axes (Country excluded per Driver): Region / Block / People-Group / Religion.
|
||||
query: { type: String, default: '' },
|
||||
wagfRegion: { type: String, default: '' },
|
||||
wagfBlock: { type: String, default: '' },
|
||||
rop1: { type: String, default: '' }, // "People Group" axis (rop1)
|
||||
religion: { type: String, default: '' },
|
||||
|
||||
// ── presentation knobs copied from UupgsList (defaults preserve today's behaviour) ──
|
||||
perPage: { type: Number, default: 24 },
|
||||
morePerPage: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['groups-filtered'])
|
||||
|
||||
// profile-config JSON can override prayBaseUrl / languageCode / t / links (single string attr).
|
||||
const cfg = computed(() => {
|
||||
if (!props.profileConfig) return {}
|
||||
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
||||
})
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
|
||||
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl || '/research/')
|
||||
const selectUrl = computed(() => props.selectUrl || cfg.value.selectUrl || '')
|
||||
const t = computed(() => ({ ...(cfg.value.t || {}), ...(props.t || {}) }))
|
||||
|
||||
// ── Cat-2 show/hide (accept the DOM-attribute string 'false') ──────────────────
|
||||
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
|
||||
const useSearch = computed(() => truthy(props.showSearch))
|
||||
const useFilters = computed(() => truthy(props.showFilters))
|
||||
|
||||
// ── state (copied from UupgsList) ──────────────────────────────────────────────
|
||||
const uupgs = ref([])
|
||||
const filteredUUPGs = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(true)
|
||||
const filtersExpanded = ref(false)
|
||||
const filterOptions = ref({})
|
||||
|
||||
// internal (control-owned) filter state — authoritative only when the control is shown.
|
||||
const localSearchTerm = ref('')
|
||||
const localFilters = ref({}) // { wagf_region|wagf_block|rop1|religion: {value,label} } + toggles
|
||||
|
||||
// effective search term = internal (if search shown) else the query prop.
|
||||
const effSearchTerm = computed(() => (useSearch.value ? localSearchTerm.value : (props.query || '')))
|
||||
|
||||
// effective dropdown selections = internal (if filters shown) else the props.
|
||||
const propFilters = computed(() => {
|
||||
const f = {}
|
||||
const lbl = (v) => v // label unknown for prop-driven values; value doubles as label
|
||||
if (props.wagfRegion) f.wagf_region = { value: props.wagfRegion, label: lbl(props.wagfRegion) }
|
||||
if (props.wagfBlock) f.wagf_block = { value: props.wagfBlock, label: lbl(props.wagfBlock) }
|
||||
if (props.rop1) f.rop1 = { value: props.rop1, label: lbl(props.rop1) }
|
||||
if (props.religion) f.religion = { value: props.religion, label: lbl(props.religion) }
|
||||
return f
|
||||
})
|
||||
const activeFilters = computed(() => (useFilters.value ? localFilters.value : propFilters.value))
|
||||
|
||||
// Active dropdown chips the user can see/remove (only meaningful when filters are shown).
|
||||
const visibleFilterCount = computed(() => Object.keys(activeFilters.value).length)
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function debounce(fn, ms = 500) {
|
||||
let timeout
|
||||
return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => fn(...args), ms) }
|
||||
}
|
||||
|
||||
// Debounce only the FILTER pass; the term itself is captured synchronously at the
|
||||
// input event (shadow-DOM retargets event.target on the async tick, so we must NOT
|
||||
// read event.target inside the debounced closure — read it now, filter later).
|
||||
const runFilterDebounced = debounce(() => {
|
||||
loading.value = true
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
}, 500)
|
||||
// SearchBar (shared) emits `search { value }` — the value is read inside the control,
|
||||
// so shadow-DOM event retargeting on the async tick can never lose it.
|
||||
function onSearch(payload) {
|
||||
localSearchTerm.value = payload?.value ?? ''
|
||||
runFilterDebounced()
|
||||
}
|
||||
|
||||
function toggleFilters() { filtersExpanded.value = !filtersExpanded.value }
|
||||
|
||||
function onFilterChange(detail) {
|
||||
localFilters.value = { ...localFilters.value, [detail.name]: { value: detail.value, label: detail.label } }
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
}
|
||||
function onFilterClear(detail) { removeFilter(detail.name) }
|
||||
function removeFilter(name) {
|
||||
const copy = { ...localFilters.value }
|
||||
delete copy[name]
|
||||
localFilters.value = copy
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
}
|
||||
function clearAllFilters() {
|
||||
localFilters.value = {}
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
}
|
||||
function toggleExact() {
|
||||
if (localFilters.value.exact) return removeFilter('exact')
|
||||
localFilters.value = { ...localFilters.value, exact: { value: 'yes', label: t.value.exact_filter || 'Exact' } }
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
}
|
||||
|
||||
// ── copied from UupgsList: dropdown filtering + faceted counts + Fuse search ────
|
||||
function applyDropdownFilters(list) {
|
||||
let filtered = list
|
||||
for (const [field, selection] of Object.entries(activeFilters.value)) {
|
||||
if (field === 'exact') { filtered = filtered } else {
|
||||
filtered = filtered.filter((u) => {
|
||||
const vl = u[field]
|
||||
return vl?.value === selection.value
|
||||
})
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function extractFilterOptions() {
|
||||
const extract = (field) => {
|
||||
const counts = new Map()
|
||||
for (const u of uupgs.value) {
|
||||
const vl = u[field]
|
||||
if (!vl?.value) continue
|
||||
const existing = counts.get(vl.value)
|
||||
if (existing) existing.count++
|
||||
else counts.set(vl.value, { label: vl.label, count: 1 })
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([value, { label, count }]) => ({ value, label, count }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
// The FOUR axes only (Country excluded per Driver).
|
||||
filterOptions.value = {
|
||||
wagf_region: extract('wagf_region'),
|
||||
wagf_block: extract('wagf_block'),
|
||||
rop1: extract('rop1'),
|
||||
religion: extract('religion'),
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterOptionCounts() {
|
||||
const fields = Object.keys(filterOptions.value)
|
||||
const next = {}
|
||||
for (const field of fields) {
|
||||
const filtersWithout = {}
|
||||
for (const [key, val] of Object.entries(activeFilters.value)) {
|
||||
if (key !== field) filtersWithout[key] = val
|
||||
}
|
||||
// apply the OTHER filters to compute this field's cross-filtered counts
|
||||
const subset = applyWith(filtersWithout, uupgs.value)
|
||||
const counts = new Map()
|
||||
for (const u of subset) {
|
||||
const vl = u[field]
|
||||
if (!vl?.value) continue
|
||||
counts.set(vl.value, (counts.get(vl.value) ?? 0) + 1)
|
||||
}
|
||||
next[field] = (filterOptions.value[field] ?? []).map(opt => ({ ...opt, count: counts.get(opt.value) ?? 0 }))
|
||||
}
|
||||
filterOptions.value = next
|
||||
}
|
||||
|
||||
// helper: apply a specific filter-set (used by the faceted-count cross-filter)
|
||||
function applyWith(filters, list) {
|
||||
let filtered = list
|
||||
for (const [field, selection] of Object.entries(filters)) {
|
||||
if (field === 'exact') continue
|
||||
filtered = filtered.filter((u) => u[field]?.value === selection.value)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function filterUUPGs() {
|
||||
// reset per-item highlight state
|
||||
uupgs.value = uupgs.value.map((u) => {
|
||||
u.matches = []
|
||||
u.country_label = ''
|
||||
u.rop1_label = ''
|
||||
return u
|
||||
})
|
||||
|
||||
const hasActive = Object.keys(activeFilters.value).length > 0
|
||||
const preFiltered = hasActive ? applyDropdownFilters(uupgs.value) : uupgs.value
|
||||
const term = effSearchTerm.value
|
||||
|
||||
if (!term) {
|
||||
filteredUUPGs.value = preFiltered
|
||||
total.value = filteredUUPGs.value.length
|
||||
loading.value = false
|
||||
updateFilterOptionCounts()
|
||||
emitFiltered()
|
||||
return
|
||||
}
|
||||
|
||||
const fuse = new Fuse(preFiltered, {
|
||||
includeMatches: true,
|
||||
ignoreLocation: true,
|
||||
minMatchCharLength: 3,
|
||||
threshold: activeFilters.value.exact ? 0 : 0.4,
|
||||
keys: ['name', 'imb_alternate_name', 'country_code.label', 'rop1.label', 'religion.label', 'wagf_region.label', 'wagf_block.label'],
|
||||
})
|
||||
|
||||
const result = fuse.search(term)
|
||||
filteredUUPGs.value = result.map((res) => {
|
||||
const newItem = { ...res.item }
|
||||
if (!res.matches) return newItem
|
||||
newItem.matches = []
|
||||
for (const match of res.matches) {
|
||||
const matchKey = match.key
|
||||
if (!matchKey) continue
|
||||
let value = ''
|
||||
if (String(matchKey).includes('.')) {
|
||||
const [parentKey, childKey] = String(matchKey).split('.')
|
||||
value = newItem[parentKey]?.[childKey]
|
||||
} else {
|
||||
value = newItem[String(matchKey)]
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
let currentIndex = 0
|
||||
let highlightedValue = ''
|
||||
match.indices.forEach((idx, i) => {
|
||||
const start = idx[0]
|
||||
const end = idx[1]
|
||||
const isLast = match.indices.length - 1 === i
|
||||
highlightedValue
|
||||
+= escapeHtml(value.slice(currentIndex, start))
|
||||
+ `<span class="search-highlight">${escapeHtml(value.slice(start, end + 1))}</span>`
|
||||
+ (isLast ? escapeHtml(value.slice(end + 1)) : '')
|
||||
currentIndex = end + 1
|
||||
})
|
||||
if (String(matchKey).includes('imb_alternate_name')) {
|
||||
newItem.matches.push({ key: t.value.alternate_name ?? 'Also known as', label: highlightedValue })
|
||||
} else if (String(matchKey).includes('.')) {
|
||||
const [parentKey] = String(matchKey).split('.')
|
||||
const keyTranslations = {
|
||||
religion: t.value.religion,
|
||||
country_code: t.value.country,
|
||||
rop1: t.value.rop1,
|
||||
wagf_region: t.value.wagf_region,
|
||||
wagf_block: t.value.wagf_block,
|
||||
}
|
||||
if (parentKey === 'rop1') { newItem.rop1_label = highlightedValue }
|
||||
else if (parentKey === 'country_code') { newItem.country_label = highlightedValue }
|
||||
else if (parentKey === 'wagf_region') { /* no dedicated slot */ }
|
||||
else { newItem.matches.push({ key: keyTranslations[parentKey] ?? parentKey, label: highlightedValue }) }
|
||||
} else {
|
||||
newItem[String(matchKey)] = highlightedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return newItem
|
||||
})
|
||||
|
||||
total.value = filteredUUPGs.value.length
|
||||
loading.value = false
|
||||
updateFilterOptionCounts()
|
||||
emitFiltered()
|
||||
}
|
||||
|
||||
// ── emit `groups-filtered` (Vue + native composed CustomEvent) ─────────────────
|
||||
const inst = getCurrentInstance()
|
||||
function hostEl() {
|
||||
const root = inst?.vnode?.el || inst?.proxy?.$el
|
||||
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return root
|
||||
}
|
||||
function emitFiltered() {
|
||||
const detail = {
|
||||
query: effSearchTerm.value,
|
||||
activeFilters: activeFilters.value,
|
||||
total: total.value,
|
||||
slugs: filteredUUPGs.value.map((u) => u.slug),
|
||||
}
|
||||
emit('groups-filtered', detail)
|
||||
const el = hostEl()
|
||||
if (el && typeof el.dispatchEvent === 'function') {
|
||||
el.dispatchEvent(new CustomEvent('groups-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// ── pagination (copied from UupgsList) ─────────────────────────────────────────
|
||||
function hasMore() {
|
||||
if (props.morePerPage > 0) {
|
||||
return total.value > props.perPage + (page.value - 1) * props.morePerPage && !loading.value && filteredUUPGs.value.length > 0
|
||||
}
|
||||
return total.value > page.value * props.perPage && !loading.value && filteredUUPGs.value.length > 0
|
||||
}
|
||||
function loadMore() { page.value = page.value + 1 }
|
||||
const displayedUUPGs = computed(() => {
|
||||
if (props.morePerPage > 0) return filteredUUPGs.value.slice(0, props.perPage + (page.value - 1) * props.morePerPage)
|
||||
return filteredUUPGs.value.slice(0, page.value * props.perPage)
|
||||
})
|
||||
const showMore = computed(() => hasMore())
|
||||
|
||||
// ── data fetch (SAME endpoint DX-life uses; host now injected) ─────────────────
|
||||
async function getUUPGs() {
|
||||
const url = `${prayBaseUrl.value}/api/people-groups/list?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&lang=${languageCode.value}`
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
total.value = data.total
|
||||
uupgs.value = Array.isArray(data.posts) ? data.posts : []
|
||||
extractFilterOptions()
|
||||
filterUUPGs()
|
||||
} catch (error) {
|
||||
console.error('[people-groups-grid] fetch error:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// external prop-driven filters should re-filter the grid (when controls are hidden).
|
||||
watch(() => [props.query, props.wagfRegion, props.wagfBlock, props.rop1, props.religion, useSearch.value, useFilters.value], () => {
|
||||
page.value = 1
|
||||
filterUUPGs()
|
||||
})
|
||||
|
||||
onMounted(getUUPGs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pgg-root">
|
||||
<!-- The element's OWN filters block: shown by default, hidden when driven externally. -->
|
||||
<div v-if="useSearch || useFilters" id="filters" class="filters">
|
||||
<div v-if="useSearch" class="pgg-search">
|
||||
<SearchBar
|
||||
:model-value="localSearchTerm"
|
||||
:placeholder="t.search || 'Search people groups…'"
|
||||
:aria-label="t.search || 'Search people groups'"
|
||||
max-width="480px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="useFilters"
|
||||
class="filters__toggle"
|
||||
type="button"
|
||||
:aria-expanded="filtersExpanded"
|
||||
@click="toggleFilters"
|
||||
>
|
||||
{{ filtersExpanded ? (t.hide_filters || 'Hide Filters') : (t.show_filters || 'Show Filters') }}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<template v-if="useFilters && filtersExpanded">
|
||||
<!-- THE FOUR dropdowns: Region / Block / People-Group / Religion (Country EXCLUDED). -->
|
||||
<div class="filters__panel">
|
||||
<FilterDropdown
|
||||
:label="t.wagf_region || 'Region'" name="wagf_region"
|
||||
:options="filterOptions.wagf_region || []"
|
||||
:value="activeFilters.wagf_region?.value ?? ''"
|
||||
:placeholder="t.type_to_search || 'Type to search...'"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
:label="t.wagf_block || 'Block'" name="wagf_block"
|
||||
:options="filterOptions.wagf_block || []"
|
||||
:value="activeFilters.wagf_block?.value ?? ''"
|
||||
:placeholder="t.type_to_search || 'Type to search...'"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
:label="t.rop1 || 'People Group'" name="rop1"
|
||||
:options="filterOptions.rop1 || []"
|
||||
:value="activeFilters.rop1?.value ?? ''"
|
||||
:placeholder="t.type_to_search || 'Type to search...'"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
<FilterDropdown
|
||||
:label="t.religion || 'Religion'" name="religion"
|
||||
:options="filterOptions.religion || []"
|
||||
:value="activeFilters.religion?.value ?? ''"
|
||||
:placeholder="t.type_to_search || 'Type to search...'"
|
||||
@filter-change="onFilterChange" @filter-clear="onFilterClear"
|
||||
/>
|
||||
</div>
|
||||
<div class="filters__panel">
|
||||
<button
|
||||
class="filter-toggle" type="button"
|
||||
:data-active="activeFilters.exact ? '' : undefined"
|
||||
@click="toggleExact"
|
||||
>{{ t.exact_filter || 'Exact' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="useFilters && visibleFilterCount > 0" class="filters__active">
|
||||
<template v-for="(filter, name) in activeFilters" :key="name">
|
||||
<span class="filter-chip">
|
||||
{{ filter.label }}
|
||||
<button class="filter-chip__remove" type="button" @click="removeFilter(String(name))">×</button>
|
||||
</span>
|
||||
</template>
|
||||
<button class="filters__clear-all" type="button" @click="clearAllFilters">
|
||||
{{ t.clear_all || 'Clear All' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pgg-body">
|
||||
<div class="repel">
|
||||
<div class="font-size-sm">
|
||||
<template v-if="!loading">{{ t.total || 'Total' }}: {{ total }}</template>
|
||||
<span v-else> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results" class="grid uupgs-list">
|
||||
<template v-for="uupg in displayedUUPGs" :key="uupg.slug">
|
||||
<div class="card uupg__card">
|
||||
<img v-if="uupg.image_url" class="uupg__image" :src="uupg.image_url" :alt="uupg.name">
|
||||
<div class="uupg__header">
|
||||
<h3 class="uupg__name" v-html="uupg.name" />
|
||||
<p v-if="uupg.country_code" class="uupg__country">
|
||||
<a
|
||||
class="uupg__country-link"
|
||||
:href="`${researchUrl}../countries/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`"
|
||||
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a>
|
||||
<template v-if="uupg.rop1"> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1.label" />)</template>
|
||||
</p>
|
||||
<template v-if="uupg.matches">
|
||||
<p v-for="(match, mi) in uupg.matches" :key="mi" class="uupg__match">
|
||||
<strong>{{ match.key }}</strong>: <span v-html="match.label" />
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="uupg.location_description" class="uupg__content">{{ uupg.location_description }}</p>
|
||||
<a class="uupg__more-button" :href="`${researchUrl}${uupg.slug}`">{{ t.full_profile || 'Full profile' }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="loading">{{ t.loading || 'Loading…' }}</div>
|
||||
<div v-else-if="!displayedUUPGs.length" class="pgg-empty">
|
||||
{{ t.no_results || 'No people groups match this filter.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="showMore" class="pgg-loadmore" type="button" @click="loadMore">
|
||||
{{ t.load_more || 'Load more' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Self-contained styling: the DX-life grid relied on the app's shared CUBE SCSS
|
||||
(blocks/_filters.scss + blocks/_cards.scss); those don't ship here, so the essential
|
||||
layout/look is inlined and scoped to this element's shadow root. Class names are
|
||||
preserved so the DX-life SCSS COULD still apply if this ever mounts inside that app. */
|
||||
.pgg-root {
|
||||
--brand: #2563eb; --ink: #0f172a; --mut: #64748b; --line: #e2e8f0; --card: #fff; --bg: #f8fafc;
|
||||
width: 100%; box-sizing: border-box; color: var(--ink); background: var(--bg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
padding: 16px;
|
||||
}
|
||||
.filters { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
|
||||
/* The search box itself is the shared @map SearchBar (self-styling). This wrapper only
|
||||
centres it; the input's look lives in library/components/search/SearchBar.vue. */
|
||||
.pgg-search { display: flex; justify-content: center; }
|
||||
.filters__toggle {
|
||||
background: none; border: none; cursor: pointer; font-size: 0.875rem; color: var(--brand);
|
||||
display: inline-flex; align-items: center; gap: 4px; margin-inline: auto;
|
||||
}
|
||||
.filters__toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
|
||||
.filters__panel { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; align-items: flex-start; }
|
||||
.filter-toggle {
|
||||
padding: 6px 12px; cursor: pointer; font-size: 0.875rem; color: inherit; background: var(--card);
|
||||
border: 1px solid #cbd5e1; border-radius: 8px; transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.filter-toggle:hover { border-color: var(--brand); }
|
||||
.filter-toggle[data-active] { background-color: rgba(37, 99, 235, 0.08); border-color: var(--brand); color: var(--brand); }
|
||||
.filters__active { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
|
||||
.filter-chip {
|
||||
display: inline-flex; gap: 6px; align-items: center; background-color: rgba(37, 99, 235, 0.1);
|
||||
color: var(--brand); border-radius: 100px; padding: 4px 12px; font-size: 0.85rem;
|
||||
}
|
||||
.filter-chip__remove { background: none; border: none; 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: none; cursor: pointer; font-size: 0.875rem; color: var(--brand); text-decoration: underline; text-underline-offset: 4px; }
|
||||
.repel { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.font-size-sm { font-size: 0.875rem; color: var(--mut); }
|
||||
.grid.uupgs-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||||
.card.uupg__card {
|
||||
display: flex; flex-direction: column; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; overflow: hidden; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.uupg__image { width: 100%; height: 130px; object-fit: cover; background: #e2e8f0; }
|
||||
.uupg__header { padding: 12px 14px 6px; }
|
||||
.uupg__name { font-size: 1rem; font-weight: 700; line-height: 1.2; margin: 0 0 4px; }
|
||||
.uupg__country { font-size: 0.85rem; color: var(--mut); margin: 0; }
|
||||
.uupg__country-link { color: var(--brand); text-decoration: none; }
|
||||
.uupg__country-link:hover { text-decoration: underline; }
|
||||
.uupg__match { font-size: 0.8rem; color: var(--mut); margin: 4px 0 0; }
|
||||
.uupg__content { padding: 0 14px; font-size: 0.85rem; color: var(--mut); }
|
||||
.uupg__more-button {
|
||||
margin: 12px 14px 14px; padding: 8px 12px; text-align: center; font-size: 0.85rem; font-weight: 600;
|
||||
color: #fff; background: var(--brand); border-radius: 8px; text-decoration: none;
|
||||
}
|
||||
.uupg__more-button:hover { background: #1d4ed8; }
|
||||
.search-highlight { background: #fde68a; border-radius: 2px; padding: 0 1px; }
|
||||
.loading, .pgg-empty { grid-column: 1 / -1; padding: 28px; text-align: center; color: var(--mut); font-size: 0.9rem; }
|
||||
.pgg-loadmore {
|
||||
display: block; margin: 20px auto 0; padding: 8px 18px; font-size: 0.9rem; cursor: pointer;
|
||||
color: var(--brand); background: var(--card); border: 1px solid var(--brand); border-radius: 8px;
|
||||
}
|
||||
.pgg-loadmore:hover { background: rgba(37, 99, 235, 0.06); }
|
||||
</style>
|
||||
18
plugin-designs/people-groups-grid/src/countries.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* countries.js — a self-contained slugifier for country links, so the grid needs
|
||||
* NO Nuxt `~~/config/countries-meta` import (unavailable outside the DX-life app).
|
||||
*
|
||||
* COPY-ONLY NOTE: DX-life's countrySlug(code, label) prefers a canonical English name
|
||||
* from a master map; here we slugify the provided label (already the localized/English
|
||||
* country label the API returns) or fall back to the code. The link target base is a
|
||||
* prop (researchUrl) so the host controls where country links point.
|
||||
*/
|
||||
export function countrySlug(code, fallbackLabel) {
|
||||
const src = String(fallbackLabel || code || '').trim()
|
||||
return src
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // strip combining diacritics
|
||||
.replace(/[^a-z0-9]+/g, '-') // any run of non-alphanumerics -> single hyphen
|
||||
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
"my-upg-100-list-map",
|
||||
"doxa-vision-card",
|
||||
"all-people-groups",
|
||||
"unified-research-dashboard",
|
||||
"research-dashboard"
|
||||
],
|
||||
"label": "Plugin Studio (cap-5 developer studio: every element by capability 5 -> 1)"
|
||||
|
|
|
|||
|
|
@ -24,91 +24,156 @@
|
|||
The tab manifest is fetched at runtime from the built tree's manifest.json (so the studio stays
|
||||
correct as bundles change), grouped by capability via the manifest's baked tier; a compact baked
|
||||
fallback keeps it working under file:// or if the fetch fails. treeBase is derived by index.js
|
||||
from the running <script> URL (two levels up), overridable via tree-base="" or profile-config.
|
||||
from the running script URL (two levels up), overridable via tree-base="" or profile-config.
|
||||
|
||||
★ SHARED SHELL. The top bar + capability-grouped vertical rail + content frame are the ONE shared
|
||||
beautiful layout, VerticalTabsLayout (library/layouts) — the same shell doxa-maps-spa uses. The
|
||||
studio supplies the grouped rail model + owns the content area (the embed toolbar + the panes
|
||||
with their delicate direct-JS / lazy-iframe mount machinery).
|
||||
|
||||
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
|
||||
owns — the only tag this bundle defines is <plugin-studio> (see index.js).
|
||||
-->
|
||||
<template>
|
||||
<div class="ps" :class="{ 'nav-open': navOpen }">
|
||||
<header class="ps-top">
|
||||
<button class="ps-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
aria-label="Toggle element list">☰</button>
|
||||
<b class="ps-title">{{ title }}</b>
|
||||
<!-- The SHELL (top bar + capability-grouped vertical rail + content frame) is the ONE shared
|
||||
beautiful layout, VerticalTabsLayout (library/layouts). The studio supplies the grouped rail
|
||||
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
|
||||
panes with their delicate direct-JS / lazy-iframe mount machinery (UNCHANGED). -->
|
||||
<VerticalTabsLayout
|
||||
:title="title"
|
||||
nav-label="Plugin elements · by capability 5 → 1"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="select">
|
||||
|
||||
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
|
||||
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
||||
<template #brand>
|
||||
<span class="ps-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#00DC82" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 3v3M14 3v3M7 6h10v5a5 5 0 0 1-10 0V6Z" />
|
||||
<path d="M12 16v5" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- #meta: the bundle/element count + an i18n locale toggle. The studio previews LOCALISED
|
||||
elements, so a locale switch lets a developer see each element in another language. Reuses
|
||||
app-wrapper-material/languages.js (persisted, offline, no dep) — same switcher as the SPA. -->
|
||||
<template #meta>
|
||||
<span class="ps-count">{{ countLabel }}</span>
|
||||
</header>
|
||||
<span class="ps-locale">
|
||||
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
|
||||
aria-label="Change language" @click="localeOpen = !localeOpen">
|
||||
<span class="ps-flag">{{ activeLang.flag }}</span>
|
||||
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
|
||||
<span class="ps-dots" aria-hidden="true">⋮</span>
|
||||
</button>
|
||||
<ul class="ps-locale-menu" v-if="localeOpen" role="listbox">
|
||||
<li v-for="l in switcherLangs" :key="l.code" role="option" :aria-selected="String(l.code === locale)">
|
||||
<button type="button" class="ps-locale-opt" :class="{ active: l.code === locale }" @click="setLocale(l.code)">
|
||||
<span class="ps-flag">{{ l.flag }}</span>
|
||||
<span class="ps-locale-name">{{ l.nativeName }}</span>
|
||||
<span class="ps-locale-en">{{ l.name }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="ps-body">
|
||||
<nav class="ps-nav" aria-label="Plugin elements · by capability 5 → 1" v-show="navOpen">
|
||||
<div class="ps-grp" v-for="grp in groups" :key="grp.cap">
|
||||
<div class="ps-tier">
|
||||
<span class="ps-cap">CAP {{ grp.cap }}</span>
|
||||
<span class="ps-tl">{{ grp.label }}</span>
|
||||
<span class="ps-td">{{ grp.rows.length }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="row in grp.rows" :key="row.key" type="button" class="ps-tab"
|
||||
:class="{ active: row.key === activeKey }"
|
||||
:aria-current="String(row.key === activeKey)"
|
||||
@click="select(row.key)">
|
||||
<span class="ps-tab-emoji" aria-hidden="true">{{ row.emoji }}</span>
|
||||
<span class="ps-tab-text">
|
||||
<span class="ps-tab-label">{{ row.label }}</span>
|
||||
<span class="ps-tab-sub"><code><{{ row.tag }}></code></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="ps-main">
|
||||
<div class="ps-bar" v-if="active">
|
||||
<!-- Toolbar strip: the active element's identity + its copy-embed control. -->
|
||||
<template #toolbar>
|
||||
<div class="ps-toolbar" v-if="active">
|
||||
<div class="ps-bar">
|
||||
<div class="ps-bar-t">
|
||||
<b>{{ active.label }}</b>
|
||||
<span class="ps-bar-sub">{{ active.bundle }} · <{{ active.tag }}><template v-if="active.description"> · {{ active.description }}</template></span>
|
||||
<span class="ps-bar-sub">{{ active.bundle }} · <{{ active.tag }}><span v-if="active.description"> · {{ active.description }}</span></span>
|
||||
</div>
|
||||
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
|
||||
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
|
||||
</div>
|
||||
|
||||
<div class="ps-embed" v-if="embedOpen && active && active.embed">
|
||||
<div class="ps-embed" v-if="embedOpen && active.embed">
|
||||
<label>script embed
|
||||
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
|
||||
</label>
|
||||
<textarea readonly :value="active.embed"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All panes stay mounted once loaded so switching back is instant + state survives. -->
|
||||
<div class="ps-stage">
|
||||
<section
|
||||
v-for="row in flatRows" :key="row.key" class="ps-pane"
|
||||
v-show="row.key === activeKey" :hidden="row.key !== activeKey">
|
||||
<!-- All panes stay mounted once loaded so switching back is instant + state survives. -->
|
||||
<div class="ps-stage">
|
||||
<section
|
||||
v-for="row in flatRows" :key="row.key" class="ps-pane"
|
||||
v-show="row.key === activeKey" :hidden="row.key !== activeKey">
|
||||
|
||||
<template v-if="row.mode === 'element'">
|
||||
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
|
||||
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
|
||||
<div v-else-if="row.state === 'error'" class="ps-hint err">
|
||||
Could not load {{ row.label }}.<br><small>{{ row.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="row.mode === 'element'">
|
||||
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
|
||||
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
|
||||
<div v-else-if="row.state === 'error'" class="ps-hint err">
|
||||
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<iframe
|
||||
v-else
|
||||
class="ps-frame"
|
||||
:src="row.loaded ? row.src : 'about:blank'"
|
||||
:title="row.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</section>
|
||||
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
||||
</div>
|
||||
</main>
|
||||
<iframe
|
||||
v-else
|
||||
class="ps-frame"
|
||||
:src="row.loaded ? row.src : 'about:blank'"
|
||||
:title="row.label"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</section>
|
||||
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
||||
</div>
|
||||
</div>
|
||||
</VerticalTabsLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
|
||||
// Only the studio's CONTENT-AREA styles remain here; the shell (top bar + rail + body) is now the
|
||||
// shared VerticalTabsLayout (which self-injects its own CSS). Kept as an injected string so the studio
|
||||
// content is styled even when this component is nested as a child element (see useShadowStyles).
|
||||
const PS_SHADOW_CSS = `
|
||||
.ps-logo { display:inline-flex; align-items:center; }
|
||||
.ps-logo svg { display:block; }
|
||||
.ps-count { color:#a3a3a3; }
|
||||
.ps-locale { position:relative; display:inline-flex; }
|
||||
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; }
|
||||
.ps-locale-btn:hover { background:#404040; }
|
||||
.ps-flag { font-size:13px; line-height:1; }
|
||||
.ps-locale-name { max-width:11ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ps-dots { color:#a3a3a3; }
|
||||
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #404040; border-radius:10px; background:#1f1f1f; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
|
||||
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#d4d4d4; cursor:pointer; }
|
||||
.ps-locale-opt:hover { background:#404040; color:#f5f5f5; }
|
||||
.ps-locale-opt.active { background:#404040; color:#f5f5f5; }
|
||||
.ps-locale-opt.active .ps-locale-name { color:#00DC82; }
|
||||
.ps-locale-en { margin-left:auto; font-size:11px; color:#737373; }
|
||||
.ps-toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 12px 0; }
|
||||
.ps-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.ps-bar-t { flex:1 1 auto; min-width:0; }
|
||||
.ps-bar-t b { font-size:15px; color:#f5f5f5; }
|
||||
.ps-bar-sub { display:block; font-size:12px; color:#a3a3a3; }
|
||||
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #404040; border-radius:8px; background:#262626; color:#f5f5f5; cursor:pointer; white-space:nowrap; }
|
||||
.ps-btn:hover { background:#404040; }
|
||||
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #262626; border-radius:8px; background:#171717; }
|
||||
.ps-embed label { font-size:12px; color:#a3a3a3; display:flex; justify-content:space-between; align-items:center; }
|
||||
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #404040; border-radius:6px; background:#0d0d0d; color:#f5f5f5; padding:6px; resize:vertical; }
|
||||
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #262626; border-radius:10px; overflow:hidden; background:#171717; }
|
||||
.ps-pane { position:absolute; inset:0; }
|
||||
.ps-slot { position:absolute; inset:0; }
|
||||
.ps-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#171717; }
|
||||
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#a3a3a3; font-size:13px; padding:20px; }
|
||||
.ps-hint.err { color:#f87171; }
|
||||
.ps-hint small { color:#737373; }
|
||||
`
|
||||
|
||||
// Reader-facing capability names, most-composed first (5 → 1). Mirrors generate-gallery's
|
||||
// CAP_LABEL so the studio speaks the same vocabulary as the built gallery + the docs.
|
||||
|
|
@ -149,6 +214,7 @@ function loadScriptOnce(url, attrs) {
|
|||
|
||||
export default defineComponent({
|
||||
name: 'PluginStudio',
|
||||
components: { VerticalTabsLayout },
|
||||
props: {
|
||||
treeBase: { type: String, default: '' },
|
||||
profileConfig: { type: [String, Object], default: '' },
|
||||
|
|
@ -157,14 +223,21 @@ export default defineComponent({
|
|||
tk: { type: String, default: '' }
|
||||
},
|
||||
setup() {
|
||||
// Inject the studio's content-area CSS into the shadow root (the shell CSS now belongs to the
|
||||
// shared VerticalTabsLayout, which self-injects). Idempotent-by-key; the shell + this content
|
||||
// together render the full studio inside its shadow DOM.
|
||||
useShadowStyles(PS_SHADOW_CSS, 'plugin-studio')
|
||||
const injectedBase = inject('psTreeBase', '')
|
||||
return { injectedBase }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
navOpen: true,
|
||||
activeKey: '',
|
||||
embedOpen: false,
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
localeOpen: false,
|
||||
switcherLangs: SWITCHER_LANGUAGES,
|
||||
// rows keyed by "<bundle>/<profile>"; each carries its resolved nesting state.
|
||||
rows: [] // flat list built from the manifest (grouped for display via `groups`)
|
||||
}
|
||||
|
|
@ -189,20 +262,54 @@ export default defineComponent({
|
|||
.filter(c => byCap[c] && byCap[c].length)
|
||||
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
||||
},
|
||||
// The GROUPED rail model the shared VerticalTabsLayout paints. Each tier → a group with its CAP
|
||||
// badge + count; each row keeps its `key` (so select(key) is unchanged) and shows the element's
|
||||
// <tag> as its sub-label (v-html, so the monospace <code> renders — the tag text is our own,
|
||||
// not user input). Emoji per capability tier.
|
||||
navGroups() {
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return this.groups.map(g => ({
|
||||
key: 'cap-' + g.cap,
|
||||
badge: 'CAP ' + g.cap,
|
||||
label: g.label,
|
||||
count: g.rows.length,
|
||||
rows: g.rows.map(r => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
emoji: r.emoji,
|
||||
sub: '<code><' + esc(r.tag) + '></code>'
|
||||
}))
|
||||
}))
|
||||
},
|
||||
active() { return this.rows.find(r => r.key === this.activeKey) || null },
|
||||
countLabel() {
|
||||
const nb = new Set(this.rows.map(r => r.bundle)).size
|
||||
return nb + ' bundles · ' + this.rows.length + ' elements'
|
||||
}
|
||||
},
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
|
||||
},
|
||||
mounted() {
|
||||
this.loadManifest()
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
methods: {
|
||||
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
|
||||
// Switch preview language. languages.js's contract: switching = re-setting profile-config.locale
|
||||
// on the mounted element. Persist + best-effort re-set the active element (direct-JS panes carry a
|
||||
// real element; iframe panes carry their own locale ladder). No router, no dep.
|
||||
setLocale(code) {
|
||||
this.locale = code
|
||||
this.localeOpen = false
|
||||
saveLocale(code)
|
||||
try {
|
||||
const slot = this._slots && this._slots[this.activeKey]
|
||||
const el = slot && slot.firstElementChild
|
||||
if (el && el.setAttribute && this.active) {
|
||||
const pc = { profile: this.active.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
||||
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
||||
el.setAttribute('profile-config', JSON.stringify(pc))
|
||||
}
|
||||
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
||||
},
|
||||
resolve(ref) {
|
||||
const r = String(ref).replace(/^\.\//, '')
|
||||
if (this.base) return this.base + r
|
||||
|
|
@ -268,9 +375,6 @@ export default defineComponent({
|
|||
} else if (!row.mounted) {
|
||||
this.mountElement(row)
|
||||
}
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
withToken(url) {
|
||||
const tokenUrl = (this.tokenUrl || this.cfg.tokenUrl || '').trim()
|
||||
|
|
@ -313,65 +417,39 @@ export default defineComponent({
|
|||
</script>
|
||||
|
||||
<style>
|
||||
/* Scoped by the custom-element shadow root (defineCustomElement) — these styles do not leak. */
|
||||
.ps {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
height: 100%; width: 100%; min-height: 420px;
|
||||
background: #0b0f14; color: #e5e7eb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
.ps-top {
|
||||
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-bottom: 1px solid #1f2937; background: #0e131a;
|
||||
}
|
||||
.ps-menu {
|
||||
display: none; font: 16px/1 system-ui; color: #e5e7eb; background: #1f2937;
|
||||
border: 1px solid #374151; border-radius: 8px; padding: 4px 9px; cursor: pointer;
|
||||
}
|
||||
.ps-title { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.ps-count { color: #9ca3af; font-size: 12px; margin-left: auto; }
|
||||
.ps-body { flex: 1 1 auto; display: flex; min-height: 0; }
|
||||
.ps-nav {
|
||||
flex: 0 0 258px; overflow: auto; border-right: 1px solid #1f2937;
|
||||
padding: 6px 0 20px; background: #0d1219;
|
||||
}
|
||||
.ps-tier { padding: 12px 14px 3px; display: flex; align-items: baseline; gap: 7px; }
|
||||
.ps-cap { font: 700 10px/1 system-ui; letter-spacing: .5px; color: #0b0f14; background: #64748b; border-radius: 999px; padding: 3px 7px; }
|
||||
.ps-tl { font-weight: 600; font-size: 12px; }
|
||||
.ps-td { color: #64748b; font-size: 11px; margin-left: auto; }
|
||||
.ps-tab {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||
border: 0; background: none; color: #cbd5e1; padding: 8px 14px; cursor: pointer;
|
||||
border-left: 3px solid transparent; font: inherit;
|
||||
}
|
||||
.ps-tab:hover { background: #131a24; color: #e5e7eb; }
|
||||
.ps-tab.active { background: #131c28; color: #fff; border-left-color: #38bdf8; }
|
||||
.ps-tab-emoji { font-size: 16px; line-height: 1; flex: 0 0 auto; }
|
||||
.ps-tab-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.ps-tab-label { font-size: 13px; font-weight: 600; }
|
||||
.ps-tab-sub { font-size: 11px; color: #7c8794; }
|
||||
.ps-tab-sub code { font-family: ui-monospace, Menlo, monospace; }
|
||||
.ps-main { flex: 1 1 auto; position: relative; min-width: 0; display: flex; flex-direction: column; padding: 10px 12px 12px; gap: 8px; }
|
||||
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
||||
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
||||
Reskinned to the dx-life admin tokens (neutral surfaces, green #00DC82 accent) to match the shell. */
|
||||
.ps-logo { display: inline-flex; align-items: center; }
|
||||
.ps-logo svg { display: block; }
|
||||
.ps-count { color: #a3a3a3; }
|
||||
.ps-locale { position: relative; display: inline-flex; }
|
||||
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; }
|
||||
.ps-locale-btn:hover { background: #404040; }
|
||||
.ps-flag { font-size: 13px; line-height: 1; }
|
||||
.ps-locale-name { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ps-dots { color: #a3a3a3; }
|
||||
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #404040; border-radius: 10px; background: #1f1f1f; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
|
||||
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #d4d4d4; cursor: pointer; }
|
||||
.ps-locale-opt:hover { background: #404040; color: #f5f5f5; }
|
||||
.ps-locale-opt.active { background: #404040; color: #f5f5f5; }
|
||||
.ps-locale-opt.active .ps-locale-name { color: #00DC82; }
|
||||
.ps-locale-en { margin-left: auto; font-size: 11px; color: #737373; }
|
||||
.ps-toolbar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px 0; }
|
||||
.ps-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ps-bar-t { flex: 1 1 auto; min-width: 0; }
|
||||
.ps-bar-t b { font-size: 15px; }
|
||||
.ps-bar-sub { display: block; font-size: 12px; color: #9ca3af; }
|
||||
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #374151; border-radius: 8px; background: #1f2937; color: #e5e7eb; cursor: pointer; white-space: nowrap; }
|
||||
.ps-btn:hover { background: #263243; }
|
||||
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #1f2937; border-radius: 8px; background: #0e131a; }
|
||||
.ps-embed label { font-size: 12px; color: #9ca3af; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #374151; border-radius: 6px; background: #0b0f14; color: #e5e7eb; padding: 6px; resize: vertical; }
|
||||
.ps-stage { flex: 1 1 auto; position: relative; min-height: 260px; border: 1px solid #1f2937; border-radius: 10px; overflow: hidden; background: #0b0f14; }
|
||||
.ps-bar-t b { font-size: 15px; color: #f5f5f5; }
|
||||
.ps-bar-sub { display: block; font-size: 12px; color: #a3a3a3; }
|
||||
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #404040; border-radius: 8px; background: #262626; color: #f5f5f5; cursor: pointer; white-space: nowrap; }
|
||||
.ps-btn:hover { background: #404040; }
|
||||
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #262626; border-radius: 8px; background: #171717; }
|
||||
.ps-embed label { font-size: 12px; color: #a3a3a3; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #404040; border-radius: 6px; background: #0d0d0d; color: #f5f5f5; padding: 6px; resize: vertical; }
|
||||
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #262626; border-radius: 10px; overflow: hidden; background: #171717; }
|
||||
.ps-pane { position: absolute; inset: 0; }
|
||||
.ps-slot { position: absolute; inset: 0; }
|
||||
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #0b0f14; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #94a3b8; font-size: 13px; padding: 20px; }
|
||||
.ps-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #171717; }
|
||||
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #a3a3a3; font-size: 13px; padding: 20px; }
|
||||
.ps-hint.err { color: #f87171; }
|
||||
.ps-hint small { color: #64748b; }
|
||||
@media (max-width: 760px) {
|
||||
.ps-menu { display: inline-block; }
|
||||
.ps-count { display: none; }
|
||||
.ps-nav { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; box-shadow: 0 0 24px rgba(0,0,0,.5); }
|
||||
.ps:not(.nav-open) .ps-nav { display: none; }
|
||||
}
|
||||
.ps-hint small { color: #737373; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@
|
|||
* zero panel edits — a new input source plugging into the bus.
|
||||
*
|
||||
* DATA (DASH-FIX-8): the geocoder's local suggestions come from the injected `dataStore`
|
||||
* (useDoxaSearch reads dataStore.sources[searchSourceId].features). The dashboard loads its
|
||||
* (useEntitySearch reads dataStore.sources[searchSourceId].features). The dashboard loads its
|
||||
* rows into its OWN store (useDashboardData), so useDashboardShell now ALSO normalizes those
|
||||
* already-fetched rows through the SAME DataSourceManager the map profiles use and exposes
|
||||
* them as a shared `dataStore` under `searchSourceId` (provided here via inject). That makes
|
||||
* useDoxaSearch build an IDENTICAL index → the dashboard bars produce the SAME suggestions as
|
||||
* useEntitySearch build an IDENTICAL index → the dashboard bars produce the SAME suggestions as
|
||||
* research-map, with no second network request. The bar → composable → bus wiring below is
|
||||
* independent of that and is what this kit proves.
|
||||
*/
|
||||
|
|
@ -39,7 +39,7 @@ const cfg = inject('dashboardConfig', {})
|
|||
const mapboxToken = inject('mapboxToken', ref(''))
|
||||
// The source id the shell keyed its DataSourceManager-normalized features under
|
||||
// (dataStore.sources[searchSourceId].features). Passed to each GeocoderComponent
|
||||
// as :data-source-id so useDoxaSearch reads that exact source — mirrors how the
|
||||
// as :data-source-id so useEntitySearch reads that exact source — mirrors how the
|
||||
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
|
||||
const searchSourceId = inject('searchSourceId', '')
|
||||
|
||||
|
|
|
|||
|
|
@ -170,11 +170,11 @@ export function useDashboardShell(config) {
|
|||
useHashSync(bus)
|
||||
|
||||
// ── dataStore adapter (GAP B) — DataSourceManager-normalized (DASH-FIX-2) ──
|
||||
// The header's GeocoderComponent bars (DOXA-LIB-3) run useDoxaSearch over an
|
||||
// The header's GeocoderComponent bars (DOXA-LIB-3) run useEntitySearch over an
|
||||
// injected `dataStore` to surface local people-group / country / language /
|
||||
// religion / affinity-bloc / cluster / region suggestions. The MAP profiles get
|
||||
// that dataStore from useMapData, which normalizes each raw API row through
|
||||
// DataSourceManager — producing the rich feature shape useDoxaSearch.buildIndex
|
||||
// DataSourceManager — producing the rich feature shape useEntitySearch.buildIndex
|
||||
// reads (countryName, affinityBlock[+Label], cluster[+Label], peopleGroup[+Label],
|
||||
// wagfRegion/wagfBlock[+Label], religion[+Name], language, _raw).
|
||||
//
|
||||
|
|
@ -184,7 +184,7 @@ export function useDashboardShell(config) {
|
|||
// never populated and the dashboard dropdown read WORSE than research-map's.
|
||||
// FIX: normalize the dashboard's already-loaded raw rows through the SAME
|
||||
// DataSourceManager the map uses (reusing the rows we already fetched — no second
|
||||
// network request), so useDoxaSearch builds an IDENTICAL index and the dropdown
|
||||
// network request), so useEntitySearch builds an IDENTICAL index and the dropdown
|
||||
// results CLONE research-map's. Keyed under the active source id + provided as
|
||||
// `searchSourceId` so the bars pass :data-source-id (mirrors research-map's
|
||||
// geocoder wiring, GeocoderComponent :data-source-id).
|
||||
|
|
@ -204,8 +204,8 @@ export function useDashboardShell(config) {
|
|||
dsm.normalizeData(searchSourceCfg)
|
||||
return dsm.normalizedData
|
||||
})
|
||||
// Shape matches what useDoxaSearch expects: dataStore.sources[id].features.
|
||||
// `features` is a getter over the computed so useDoxaSearch's reactive index
|
||||
// Shape matches what useEntitySearch expects: dataStore.sources[id].features.
|
||||
// `features` is a getter over the computed so useEntitySearch's reactive index
|
||||
// rebuilds when the rows load.
|
||||
const dataStore = { sources: { [searchSourceId]: { get features() { return geoFeatures.value } } } }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,40 @@
|
|||
# template-bundle — copy this to start a new map
|
||||
# template-bundle — copy this to start a new plugin
|
||||
|
||||
**This folder is the STARTER. Copy it to make a new plugin. That's the whole idea.**
|
||||
|
||||
Your starting point. **Copy this folder, then build your map inside the copy** — don't start from
|
||||
blank. It's a tiny working bundle (two trivial profiles that render a colored box, no Mapbox, no
|
||||
data) plus a `src/` sandbox laid out for your real code.
|
||||
|
||||
## In plain English (read this first)
|
||||
|
||||
If you've never touched this repo, here are the only 4 words you need:
|
||||
|
||||
| Word | Plain-English meaning |
|
||||
|---|---|
|
||||
| **plugin / bundle** | One folder = one plugin. It compiles into a **single `.js` file** you can paste onto any web page. "Bundle" and "plugin" mean the same thing here — the folder you copy. |
|
||||
| **profile** | One `.vue` file inside the folder = one **screen** (one map). A bundle can hold one profile or many. You pick which screen shows at embed time with a `profile` name. |
|
||||
| **root.vue** | Each profile IS a normal Vue component — it's the "root" (the top of the tree) for that one screen. The filename (minus `.vue`) is the profile's name. Everything that screen draws hangs off this one file. |
|
||||
| **`src/`** | Your private sandbox — your own colors, components, and helpers. Nothing in here can affect another plugin. Shared building blocks live in `library/` (imported as `@map/…`) and you never edit those. |
|
||||
|
||||
**Make a new plugin in 2 moves:**
|
||||
|
||||
```bash
|
||||
# 1. copy this starter to a new name
|
||||
cp -r plugin-designs/template-bundle plugin-designs/my-plugin
|
||||
|
||||
# 2. build (the ONE command)
|
||||
bun run build # → public/doxa-maps/my-plugin/my-plugin.js (your paste-anywhere plugin)
|
||||
```
|
||||
|
||||
Then rename the tag + profile strings (the "exactly four things" list below) so your copy has its
|
||||
own name, and rebuild. That's it — the build auto-detects your new folder, compiles it to one `.js`
|
||||
custom element, and lists it in the gallery.
|
||||
|
||||
> Proven: a copy of this folder (`cp -r` + rename the 4 strings) runs `bun run build` and emits a
|
||||
> working `<my-plugin>` custom element `.js` bundle that passes every build guard. If it builds for
|
||||
> the starter, it builds for your copy.
|
||||
|
||||
## Two zones — know which one a file is in
|
||||
|
||||
| Zone | What | Edit it? |
|
||||
|
|
|
|||
1
public/.build.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
pid=2772678 started=2026-09-19T17:20:24Z by=lk10x
|
||||
14
public/assets/vendor/vue/vue.global.prod.js
vendored
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-j5Ypx+EI9DGlTClzgLs1R8wAnmTnkQ7HgAhHrxyGoVoHDMTlFVY7slU6JNicn77T" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-VnRTqY3/t5lbgoQvvPvGL/2nao4p7xqW54pHP7dLBbBpKyl6xRf27sOMpjmyHN1K" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"all-people-groups","dataSource":"pray-tools","instanceId":"all-people-groups-all-people-groups","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
|
|
|
|||
2
public/doxa-maps/all-people-groups/index.html
generated
|
|
@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"all-people-groups","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":10,"profileCount":19,"bundles":[{"bundle":"all-people-groups","scriptUrl":"./all-people-groups/all-people-groups.js","output":"doxa-maps/all-people-groups/all-people-groups.js","integrity":"sha384-j5Ypx+EI9DGlTClzgLs1R8wAnmTnkQ7HgAhHrxyGoVoHDMTlFVY7slU6JNicn77T","elements":["all-people-groups"],"profiles":[{"profile":"all-people-groups","file":"all-people-groups.vue","description":"All 12,379 people groups (peoplegroups.org) in four colors — reached,","element":"all-people-groups","iframeUrl":"./all-people-groups/all-people-groups/index.html","scriptUrl":"./all-people-groups/all-people-groups.js","embed":{"iframe":"\u003ciframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-j5Ypx+EI9DGlTClzgLs1R8wAnmTnkQ7HgAhHrxyGoVoHDMTlFVY7slU6JNicn77T\" crossorigin=\"anonymous\">\u003c/script>\n\u003call-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/all-people-groups>"},"bundle":"all-people-groups"}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":15,"profileCount":24,"bundles":[{"bundle":"all-people-groups","scriptUrl":"./all-people-groups/all-people-groups.js","output":"doxa-maps/all-people-groups/all-people-groups.js","integrity":"sha384-VnRTqY3/t5lbgoQvvPvGL/2nao4p7xqW54pHP7dLBbBpKyl6xRf27sOMpjmyHN1K","elements":["all-people-groups"],"profiles":[{"profile":"all-people-groups","file":"all-people-groups.vue","description":"All 12,379 people groups (peoplegroups.org) in four colors — reached,","element":"all-people-groups","type":"single","isMap":true,"iframeUrl":"./all-people-groups/all-people-groups/index.html","scriptUrl":"./all-people-groups/all-people-groups.js","embed":{"iframe":"\u003ciframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-VnRTqY3/t5lbgoQvvPvGL/2nao4p7xqW54pHP7dLBbBpKyl6xRf27sOMpjmyHN1K\" crossorigin=\"anonymous\">\u003c/script>\n\u003call-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/all-people-groups>"},"bundle":"all-people-groups"}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
20
public/doxa-maps/big-picture/big-picture.js
generated
Normal file
150
public/doxa-maps/big-picture/big-picture/big-picture/index.html
generated
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Big Picture — research header ↔ people-groups grid (composite) · big-picture</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, bp-big-picture { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-UPBIDMJLOetgFVv0Il2r1zc3UxhKN+8sj1ue5J97cGOkxQsWudXH4zFShUQ3hox1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("bp-big-picture");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) { return mount(''); }
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
150
public/doxa-maps/big-picture/big-picture/frontier/index.html
generated
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Big Picture — frontier accent · big-picture</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, bp-big-picture { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-UPBIDMJLOetgFVv0Il2r1zc3UxhKN+8sj1ue5J97cGOkxQsWudXH4zFShUQ3hox1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("bp-big-picture");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) { return mount(''); }
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
150
public/doxa-maps/big-picture/big-picture/index.html
generated
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>big-picture · big-picture</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, bp-big-picture { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../big-picture.js" integrity="sha384-UPBIDMJLOetgFVv0Il2r1zc3UxhKN+8sj1ue5J97cGOkxQsWudXH4zFShUQ3hox1" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"big-picture","dataSource":"pray-tools","instanceId":"big-picture-big-picture","assetsBase":"../../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
function done() { if (status && status.parentNode) { status.parentNode.removeChild(status); } }
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("bp-big-picture");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) { return mount(''); }
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
371
public/doxa-maps/big-picture/index.html
generated
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
--cy:#46d4ff; --vi:#b9a6f5; --gold:#ffc24b; --grn:#5ee0a0;
|
||||
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 480px at 82% -12%, rgba(70,212,255,.10), transparent 60%),
|
||||
radial-gradient(820px 460px at -8% 112%, rgba(185,166,245,.10), transparent 60%),
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code { font-family: var(--mono); }
|
||||
.hidden { display: none !important; }
|
||||
.empty { color: var(--dim); font: 12px/1.5 var(--mono); padding: 18px; }
|
||||
|
||||
/* ── Shell: header strip, then sidebar|stage grid (desktop) ── */
|
||||
header.top { padding: 14px 18px 10px; border-bottom: 1px solid var(--surf2); }
|
||||
header.top h1 { font: 800 15px/1.2 var(--sans); letter-spacing: -.01em; }
|
||||
header.top h1 .ac { color: var(--cy); font-weight: 600; }
|
||||
header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut); }
|
||||
.strip { padding: 7px 18px; font: 11px/1.4 var(--mono); color: var(--mut);
|
||||
border-bottom: 1px solid var(--surf2); background: rgba(255,255,255,.012); }
|
||||
.strip .pat { color: var(--cy); } .strip .err { color: #f87171; } .strip strong { color: var(--ink); }
|
||||
|
||||
/* token bar — visible only while no token resolves */
|
||||
#tokenBar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 9px 18px; font: 12px/1.4 var(--sans); color: var(--gold);
|
||||
border-bottom: 1px solid var(--surf2); background: rgba(255,194,75,.06); }
|
||||
#tokenBar input { flex: 1 1 220px; max-width: 420px; font: 12px/1 var(--mono);
|
||||
color: var(--ink); background: var(--surf2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 10px; }
|
||||
#tokenBar button { font: 700 11px/1 var(--mono); color: #0a0e15; cursor: pointer;
|
||||
background: linear-gradient(90deg, var(--cy), var(--vi)); border: 0;
|
||||
border-radius: 999px; padding: 8px 14px; }
|
||||
|
||||
/* ── The one-page layout ── */
|
||||
#shell { display: grid; grid-template-columns: minmax(280px, 340px) 1fr;
|
||||
height: calc(100vh - 118px); min-height: 420px; }
|
||||
|
||||
/* sidebar: THE semantic tree — navigation and architecture in one */
|
||||
#treeNav { overflow-y: auto; border-right: 1px solid var(--surf2);
|
||||
padding: 12px 10px 28px; background: rgba(255,255,255,.008); }
|
||||
.bgroup { margin-bottom: 8px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surf); overflow: hidden; }
|
||||
.bgroup summary { list-style: none; cursor: pointer; display: flex; align-items: baseline;
|
||||
gap: 8px; padding: 10px 12px; font: 700 13px/1.2 var(--sans); }
|
||||
.bgroup summary::-webkit-details-marker { display: none; }
|
||||
.bgroup summary .bdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
||||
align-self: center; background: var(--gem, var(--cy)); box-shadow: 0 0 8px var(--gem, var(--cy)); }
|
||||
.bgroup summary .bmeta { margin-left: auto; font: 10px/1.3 var(--mono); color: var(--dim); font-weight: 400; }
|
||||
.bgroup[open] summary { border-bottom: 1px solid var(--surf2); }
|
||||
|
||||
.node { display: block; width: 100%; text-align: left; cursor: pointer; color: inherit;
|
||||
background: transparent; border: 0; border-left: 2px solid transparent;
|
||||
padding: 8px 12px 8px 22px; transition: background .12s; }
|
||||
.node:hover { background: rgba(255,255,255,.03); }
|
||||
.node.active { background: color-mix(in srgb, var(--gem, var(--cy)) 10%, transparent);
|
||||
border-left-color: var(--gem, var(--cy)); }
|
||||
.node .nlabel { display: block; font: 600 12.5px/1.3 var(--sans); }
|
||||
.node .nsub { display: block; font: 10.5px/1.4 var(--mono); color: var(--mut); margin-top: 1px; }
|
||||
.node .nedit { display: block; font: 9.5px/1.4 var(--mono); color: var(--dim); margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* kind glyphs */
|
||||
.node.k-instance .nlabel::before { content: "◇ "; color: var(--grn); }
|
||||
.node.k-profile .nlabel::before { content: "● "; color: var(--vi); }
|
||||
.node.k-nested .nlabel::before { content: "◉ "; color: var(--gold); }
|
||||
|
||||
/* ── Stage: the ONE live map inside its GEM FRAME (the recurring hero theme) ── */
|
||||
#stage { display: flex; flex-direction: column; min-width: 0; padding: 12px 16px 16px; }
|
||||
.stage-head { display: flex; align-items: center; gap: 10px; padding: 0 2px 9px; }
|
||||
.stage-head .sctag { font: 800 13px/1 var(--mono); color: var(--gem, var(--cy)); }
|
||||
.stage-head .sckind { font: 8.5px/1 var(--mono); color: var(--dim); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 3px 8px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.stage-head .popout { margin-left: auto; font: 600 11px/1 var(--mono); color: var(--mut);
|
||||
text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; }
|
||||
.stage-head .popout:hover { color: var(--ink); border-color: #3a4a63; }
|
||||
/* the gem frame: colored jewel border + inner ring + soft glow in the node's gem color */
|
||||
.map-slot { flex: 1 1 0; min-height: 0; position: relative; border-radius: 12px;
|
||||
border: 1px solid var(--gem, var(--cy));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gem, var(--cy)) 45%, transparent) inset,
|
||||
0 0 26px rgba(var(--gem-rgb, 70,212,255), .16),
|
||||
0 8px 30px rgba(0,0,0,.35);
|
||||
background: #0d1622; overflow: hidden; padding: 8px; }
|
||||
.map-slot iframe { position: absolute; inset: 8px; width: calc(100% - 16px);
|
||||
height: calc(100% - 16px); border: 0; border-radius: 8px; }
|
||||
|
||||
/* ── Mobile: single column — tree on top, the map takes the FULL SCREEN below ──
|
||||
(2026-07-11: mobile map = pretty much full screen; scroll must reach it.
|
||||
100dvh = dynamic viewport height, tracks the mobile URL bar; 100vh = fallback.) */
|
||||
@media (max-width: 899px) {
|
||||
html, body { height: auto; } /* let the page actually scroll */
|
||||
#shell { display: block; height: auto; }
|
||||
#treeNav { border-right: 0; border-bottom: 1px solid var(--surf2); max-height: 44vh; }
|
||||
#stage { height: 100vh; height: 100dvh; padding: 8px; } /* full-screen map pane */
|
||||
.stage-head { padding-bottom: 6px; }
|
||||
.map-slot { padding: 4px; border-radius: 10px; } /* slimmer gem frame = max map */
|
||||
.map-slot iframe { inset: 4px; width: calc(100% - 8px); height: calc(100% - 8px); }
|
||||
.node .nedit { display: none; } /* edit paths are a desktop affordance */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>doxa-maps<span class="ac"> / big-picture</span></h1>
|
||||
<div class="sub">big-picture — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
<div id="tokenBar" class="hidden"></div>
|
||||
<div id="shell">
|
||||
<nav id="treeNav" aria-label="semantic tree"></nav>
|
||||
<main id="stage"></main>
|
||||
</div>
|
||||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"big-picture","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":15,"profileCount":24,"bundles":[{"bundle":"big-picture","scriptUrl":"./big-picture/big-picture.js","output":"doxa-maps/big-picture/big-picture.js","integrity":"sha384-UPBIDMJLOetgFVv0Il2r1zc3UxhKN+8sj1ue5J97cGOkxQsWudXH4zFShUQ3hox1","elements":["bp-big-picture","bp-research-header","people-groups-grid"],"profiles":[{"profile":"big-picture","file":"big-picture.vue","description":"The Cat-4 composite that PAIRS the two real capability elements of the","element":"bp-big-picture (this composite) + bp-research-header + people-groups-grid (nested)","type":"nested-elements-mfe","isMap":false,"iframeUrl":"./big-picture/big-picture/index.html","scriptUrl":"./big-picture/big-picture.js","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./big-picture/big-picture.js\" integrity=\"sha384-UPBIDMJLOetgFVv0Il2r1zc3UxhKN+8sj1ue5J97cGOkxQsWudXH4zFShUQ3hox1\" crossorigin=\"anonymous\">\u003c/script>\n\u003cbp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/bp-big-picture>"},"instances":[{"id":"big-picture","title":"Big Picture — research header ↔ people-groups grid (composite)","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/big-picture/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}},{"id":"frontier","title":"Big Picture — frontier accent","apiBaseUrl":"","props":{"profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus"},"element":"bp-big-picture","iframeUrl":"./big-picture/big-picture/frontier/index.html","embed":{"iframe":"\u003ciframe src=\"./big-picture/big-picture/frontier/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>"}}],"bundle":"big-picture"}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
* design brief (2026-07-11, the merger of all prior staging files):
|
||||
* - ONE page. No folder-hopping: the semantic tree IS the navigation.
|
||||
* - Desktop: hierarchical tree SIDEBAR on the left (bundle → profile → instance,
|
||||
* kind-badged, "where do I edit" hints) · selected map fills the stage on the right.
|
||||
* - Mobile: tree on top, map below — scroll down to the map. Native responsive,
|
||||
* no simulator toggle.
|
||||
* - Token UX: resolve ?tk= → localStorage → none. If none, show a token bar
|
||||
* (paste once, stored locally in the browser, never in files/links unless you
|
||||
* use ?tk= on purpose). Maps must Just Work after one paste.
|
||||
* - Auto-generated: whatever plugin-designs exist, the tree detects and shows them.
|
||||
*
|
||||
* file:// contract: classic script, manifest inlined at build (window.FORGE.manifest),
|
||||
* fetch only as dev fallback. No import/export. The DEV-HMR block at the bottom is
|
||||
* stripped by generate-tree.mjs for built pages.
|
||||
*/
|
||||
|
||||
/* global window, document */
|
||||
const FORGE = window.FORGE || {}
|
||||
const SCOPE = FORGE.scope || 'root'
|
||||
const MODE = FORGE.mode || 'built'
|
||||
const MANIFEST_URL = FORGE.manifestUrl || './manifest.json'
|
||||
const HREF_BASE = FORGE.hrefBase || './'
|
||||
|
||||
const GEMS = ['#46d4ff', '#b9a6f5', '#ffc24b', '#5ee0a0', '#f97fb5', '#8ef0e4']
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
|
||||
let tree = null
|
||||
let flat = [] // flat list of selectable map nodes (profiles + instances)
|
||||
let activeKey = null // selected node key
|
||||
|
||||
// ── Token resolution (maintainer ladder: ?tk= → localStorage → ask once) ─────────
|
||||
const TK_STORE = 'MAPS_STAGING_TK'
|
||||
function resolveToken() {
|
||||
const qp = new URLSearchParams(location.search).get('tk')
|
||||
if (qp) { try { localStorage.setItem(TK_STORE, qp) } catch (e) {} return qp }
|
||||
try { return localStorage.getItem(TK_STORE) || '' } catch (e) { return '' }
|
||||
}
|
||||
let TK = resolveToken()
|
||||
|
||||
function withTk(href) {
|
||||
if (!TK) return href
|
||||
return href + (href.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK)
|
||||
}
|
||||
// Carry the page's ?provider= into the embedded map iframe so the engine choice propagates.
|
||||
function withProvider(href) {
|
||||
const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null
|
||||
return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href
|
||||
}
|
||||
|
||||
// ── Load the semantic tree (inline-first; fetch = dev fallback) ──────────────
|
||||
async function loadTree() {
|
||||
if (FORGE.manifest) return FORGE.manifest
|
||||
const res = await fetch(MANIFEST_URL, { cache: 'no-cache' })
|
||||
if (!res.ok) throw new Error('manifest ' + res.status + ' from ' + MANIFEST_URL)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const treeHref = (rel) => HREF_BASE + rel
|
||||
const hexRgb = (hex) => {
|
||||
const h = hex.replace('#', '')
|
||||
return parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16)
|
||||
}
|
||||
|
||||
function profileKind(p) {
|
||||
if (Array.isArray(p.instances) && p.instances.length) return 'parameterized'
|
||||
if (p.nested || /nested|tabs|web components/i.test(p.description || '')) return 'nested'
|
||||
return 'individual'
|
||||
}
|
||||
|
||||
// ── Build the flat selectable list + the sidebar tree ────────────────────────
|
||||
// Selectable = anything that renders as ONE map page: individual/nested profiles
|
||||
// and each instance of a parameterized profile.
|
||||
function buildFlat() {
|
||||
flat = []
|
||||
;(tree.bundles || []).forEach((b, bi) => { b.__gem = GEMS[bi % GEMS.length] })
|
||||
for (const b of tree.bundles || []) {
|
||||
for (const p of b.profiles || []) {
|
||||
const kind = profileKind(p)
|
||||
if (kind === 'parameterized') {
|
||||
for (const inst of p.instances) {
|
||||
flat.push({
|
||||
key: b.bundle + '/' + p.profile + '/' + inst.id,
|
||||
kind: 'instance', label: inst.title || inst.id,
|
||||
sub: p.profile + ' · preset "' + inst.id + '"',
|
||||
edit: b.bundle + '/' + p.profile + '.instances.json',
|
||||
href: treeHref(b.bundle + '/' + p.profile + '/' + inst.id + '/index.html'),
|
||||
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
flat.push({
|
||||
key: b.bundle + '/' + p.profile,
|
||||
kind: kind === 'nested' ? 'nested' : 'profile',
|
||||
label: p.profile,
|
||||
sub: p.description || (kind === 'nested' ? 'embeds child web components' : 'one web component = one map'),
|
||||
edit: 'plugin-designs/' + b.bundle + '/' + (p.file || p.profile + '.vue'),
|
||||
href: treeHref(b.bundle + '/' + p.profile + '/index.html'),
|
||||
bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
}
|
||||
|
||||
// Sidebar: bundles are <details> groups (open by default), each map node a row.
|
||||
function renderSidebar() {
|
||||
const el = $('#treeNav')
|
||||
if (!el) return
|
||||
const parts = []
|
||||
const bundles = tree.bundles || []
|
||||
bundles.forEach((b, bi) => {
|
||||
const gem = b.__gem || GEMS[bi % GEMS.length]
|
||||
const rows = flat.filter((n) => n.bundle === b.bundle).map((n) => `
|
||||
<button class="node k-${n.kind}${n.key === activeKey ? ' active' : ''}" data-key="${esc(n.key)}" style="--gem:${gem}">
|
||||
<span class="nlabel">${esc(n.label)}</span>
|
||||
<span class="nsub">${esc(n.sub)}</span>
|
||||
<code class="nedit">${esc(n.edit)}</code>
|
||||
</button>`).join('')
|
||||
parts.push(`
|
||||
<details class="bgroup" open style="--gem:${gem}">
|
||||
<summary><span class="bdot"></span>${esc(b.bundle)}
|
||||
<span class="bmeta">${(b.profiles || []).length} profile${(b.profiles || []).length === 1 ? '' : 's'} · one .js</span>
|
||||
</summary>
|
||||
${rows}
|
||||
</details>`)
|
||||
})
|
||||
el.innerHTML = parts.join('') || '<p class="empty">no bundles built yet</p>'
|
||||
el.querySelectorAll('.node').forEach((btn) =>
|
||||
btn.addEventListener('click', () => select(btn.dataset.key)))
|
||||
}
|
||||
|
||||
// ── Stage: the ONE live map (iframe of the node's self-contained page) ───────
|
||||
function renderStage() {
|
||||
const st = $('#stage')
|
||||
if (!st) return
|
||||
const nd = flat.find((n) => n.key === activeKey) || flat[0]
|
||||
if (!nd) { st.innerHTML = '<p class="empty">no maps in this tree</p>'; return }
|
||||
activeKey = nd.key
|
||||
const gem = nd.gem || '#46d4ff'
|
||||
st.style.setProperty('--gem', gem)
|
||||
st.style.setProperty('--gem-rgb', hexRgb(gem))
|
||||
st.innerHTML = `
|
||||
<div class="stage-head">
|
||||
<span class="sctag"><${esc(nd.element || nd.label)}></span>
|
||||
<span class="sckind">${esc(nd.kind)}</span>
|
||||
<a class="popout" href="${esc(withProvider(withTk(nd.href)))}" target="_blank" rel="noopener">open ↗</a>
|
||||
<button class="popout" id="copyEmbed" type="button" title="Copy an iframe snippet with this host's full URL">copy embed</button>
|
||||
</div>
|
||||
<div class="map-slot"><iframe allowfullscreen src="${esc(withProvider(withTk(nd.href)))}"></iframe></div>`
|
||||
// COPY EMBED — the partner handoff. The snippet is built AT CLICK TIME from
|
||||
// location.href, so it always carries the FULL URL of whatever host serves
|
||||
// this tree (localhost, doxa.life/embed/doxa-maps-build/…, any CDN) — embeds
|
||||
// work no matter where the build folder lives. utm_source placeholder =
|
||||
// per-partner traffic attribution (see the manifest snippets).
|
||||
const copyBtn = $('#copyEmbed')
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const abs = new URL(nd.href, location.href)
|
||||
abs.searchParams.set('utm_source', 'YOUR_SOURCE')
|
||||
const snip = '<iframe src="' + abs.href + '" width="100%" height="600" '
|
||||
+ 'style="border:none;border-radius:12px;" allowfullscreen></iframe>'
|
||||
let ok = false
|
||||
try { await navigator.clipboard.writeText(snip); ok = true } catch (_) {
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = snip; document.body.appendChild(ta); ta.select()
|
||||
ok = document.execCommand('copy'); ta.remove()
|
||||
} catch (_) {}
|
||||
}
|
||||
copyBtn.textContent = ok ? 'copied ✓' : 'copy failed'
|
||||
setTimeout(() => { copyBtn.textContent = 'copy embed' }, 1800)
|
||||
})
|
||||
// keep the sidebar highlight in sync
|
||||
document.querySelectorAll('#treeNav .node').forEach((b) =>
|
||||
b.classList.toggle('active', b.dataset.key === activeKey))
|
||||
}
|
||||
|
||||
function select(key) {
|
||||
activeKey = key
|
||||
renderStage()
|
||||
// Mobile: after picking from the tree, bring the map into view (scroll down to it).
|
||||
if (window.matchMedia('(max-width: 899px)').matches) {
|
||||
$('#stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token bar: shown only while no token resolves; paste once, stored locally ─
|
||||
function renderTokenBar() {
|
||||
const bar = $('#tokenBar')
|
||||
if (!bar) return
|
||||
if (TK) { bar.classList.add('hidden'); return }
|
||||
bar.classList.remove('hidden')
|
||||
bar.innerHTML = `
|
||||
<span>maps need a Mapbox token —</span>
|
||||
<input id="tkInput" type="password" placeholder="paste pk.… (stays in this browser only)" autocomplete="off" />
|
||||
<button id="tkSave">use it</button>`
|
||||
$('#tkSave').addEventListener('click', () => {
|
||||
const v = $('#tkInput').value.trim()
|
||||
if (!v) return
|
||||
TK = v
|
||||
try { localStorage.setItem(TK_STORE, v) } catch (e) {}
|
||||
bar.classList.add('hidden')
|
||||
renderStage()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
const info = $('#frameInfo')
|
||||
try {
|
||||
tree = await loadTree()
|
||||
buildFlat()
|
||||
if (info) {
|
||||
info.innerHTML = '<span class="pat">semantic tree</span> — '
|
||||
+ (tree.bundles || []).length + ' bundle(s) · ' + flat.length + ' map(s) · '
|
||||
+ '<strong>' + esc(MODE) + '</strong>'
|
||||
}
|
||||
renderSidebar()
|
||||
renderTokenBar()
|
||||
if (flat.length) { activeKey = flat[0].key; renderStage() }
|
||||
} catch (e) {
|
||||
if (info) info.innerHTML = '<span class="err">failed to load semantic tree: ' + esc(e.message) + '</span>'
|
||||
}
|
||||
}
|
||||
|
||||
boot()
|
||||
|
||||
/* dev-hmr stripped for built pages */
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
19
public/doxa-maps/cities-internet/cities-internet.js
generated
|
|
@ -5,13 +5,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>connectivity-list · cities-internet</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
|
|
@ -25,11 +22,11 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading token…</div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,8 +43,9 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"connectivity-list","dataSource":"pray-tools","instanceId":"cities-internet-connectivity-list","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
var TOKEN_FREE = false;
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"connectivity-map","dataSource":"pray-tools","instanceId":"cities-internet-connectivity-map","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>data-tables · cities-internet</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
|
|
@ -25,11 +22,11 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading token…</div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,8 +43,9 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"data-tables","dataSource":"pray-tools","instanceId":"cities-internet-data-tables","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
var TOKEN_FREE = false;
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
|
|
|||
2
public/doxa-maps/cities-internet/index.html
generated
|
|
@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"cities-internet","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":10,"profileCount":19,"bundles":[{"bundle":"cities-internet","scriptUrl":"./cities-internet/cities-internet.js","output":"doxa-maps/cities-internet/cities-internet.js","integrity":"sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z","elements":["cities-internet-map"],"profiles":[{"profile":"connectivity-list","file":"connectivity-list.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-list/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}},{"profile":"connectivity-map","file":"connectivity-map.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/connectivity-map/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"cities-internet-map","iframeUrl":"./cities-internet/data-tables/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-gPdqkrwfePG3KfeoSRe6X6laMtcVdN7tqsJXm15WUA9UWWhxW9j99P+gyKe/dl8Z\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":15,"profileCount":24,"bundles":[{"bundle":"cities-internet","scriptUrl":"./cities-internet/cities-internet.js","output":"doxa-maps/cities-internet/cities-internet.js","integrity":"sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/","elements":["cities-internet-map"],"profiles":[{"profile":"connectivity-list","file":"connectivity-list.vue","description":"","element":"cities-internet-map","type":"single","isMap":false,"iframeUrl":"./cities-internet/connectivity-list/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}},{"profile":"connectivity-map","file":"connectivity-map.vue","description":"","element":"cities-internet-map","type":"single","isMap":true,"iframeUrl":"./cities-internet/connectivity-map/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}},{"profile":"data-tables","file":"data-tables.vue","description":"","element":"cities-internet-map","type":"single","isMap":false,"iframeUrl":"./cities-internet/data-tables/index.html","scriptUrl":"./cities-internet/cities-internet.js","embed":{"iframe":"\u003ciframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-onHNclimu8UaWmMsqDNxs4TVr59rOVMvTFWHX+CJcSVJRku28rtsTyyVIBSIVsf/\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/cities-internet-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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-X9SslLiOGDh8ASpogPFqNUI34WlQHj7pMHc2hPWMQxnuZfUk67ZFpnBa17eRFLcD" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-c5v8DO/i3dhZKpGBIOyuxb5iUqtdF28LpFW1SPtcw67az2HvKR3YqLuAhUL6Xw3S" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"countries-map","dataSource":"pray-tools","instanceId":"country-posters-countries-map","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>country-poster-wall · country-posters</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
|
||||
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
|
|
@ -25,11 +22,11 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading token…</div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-X9SslLiOGDh8ASpogPFqNUI34WlQHj7pMHc2hPWMQxnuZfUk67ZFpnBa17eRFLcD" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-c5v8DO/i3dhZKpGBIOyuxb5iUqtdF28LpFW1SPtcw67az2HvKR3YqLuAhUL6Xw3S" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,8 +43,9 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"country-poster-wall","dataSource":"pray-tools","instanceId":"country-posters-country-poster-wall","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
var TOKEN_FREE = false;
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
|
|
|||
81
public/doxa-maps/country-posters/country-posters.js
generated
2
public/doxa-maps/country-posters/index.html
generated
|
|
@ -130,7 +130,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
|
||||
<script>window.FORGE = {"mode":"built","scope":"bundle","manifestUrl":"../manifest.json","bundle":"country-posters","hrefBase":"../"};
|
||||
/* file:// contract: manifest inlined at build — no fetch needed to render. */
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":10,"profileCount":19,"bundles":[{"bundle":"country-posters","scriptUrl":"./country-posters/country-posters.js","output":"doxa-maps/country-posters/country-posters.js","integrity":"sha384-X9SslLiOGDh8ASpogPFqNUI34WlQHj7pMHc2hPWMQxnuZfUk67ZFpnBa17eRFLcD","elements":["country-posters-map"],"profiles":[{"profile":"countries-map","file":"countries-map.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/countries-map/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\" integrity=\"sha384-X9SslLiOGDh8ASpogPFqNUI34WlQHj7pMHc2hPWMQxnuZfUk67ZFpnBa17eRFLcD\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/country-posters-map>"}},{"profile":"country-poster-wall","file":"country-poster-wall.vue","description":"","element":"country-posters-map","iframeUrl":"./country-posters/country-poster-wall/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\" integrity=\"sha384-X9SslLiOGDh8ASpogPFqNUI34WlQHj7pMHc2hPWMQxnuZfUk67ZFpnBa17eRFLcD\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/country-posters-map>"}}]}]};</script>
|
||||
window.FORGE.manifest = {"schema":"doxa-maps-manifest@1","generatedFrom":"plugin-designs/*/*.vue","embedBase":".","bundleCount":15,"profileCount":24,"bundles":[{"bundle":"country-posters","scriptUrl":"./country-posters/country-posters.js","output":"doxa-maps/country-posters/country-posters.js","integrity":"sha384-c5v8DO/i3dhZKpGBIOyuxb5iUqtdF28LpFW1SPtcw67az2HvKR3YqLuAhUL6Xw3S","elements":["country-posters-map"],"profiles":[{"profile":"countries-map","file":"countries-map.vue","description":"","element":"country-posters-map","type":"single","isMap":true,"iframeUrl":"./country-posters/countries-map/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\" integrity=\"sha384-c5v8DO/i3dhZKpGBIOyuxb5iUqtdF28LpFW1SPtcw67az2HvKR3YqLuAhUL6Xw3S\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/country-posters-map>"}},{"profile":"country-poster-wall","file":"country-poster-wall.vue","description":"","element":"country-posters-map","type":"single","isMap":false,"iframeUrl":"./country-posters/country-poster-wall/index.html","scriptUrl":"./country-posters/country-posters.js","embed":{"iframe":"\u003ciframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen>\u003c/iframe>","script":"\u003cscript src=\"./country-posters/country-posters.js\" integrity=\"sha384-c5v8DO/i3dhZKpGBIOyuxb5iUqtdF28LpFW1SPtcw67az2HvKR3YqLuAhUL6Xw3S\" crossorigin=\"anonymous\">\u003c/script>\n\u003ccountry-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\"}'>\u003c/country-posters-map>"}}]}]};</script>
|
||||
<script>/**
|
||||
* staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages).
|
||||
*
|
||||
|
|
|
|||
41
public/doxa-maps/demo-map/demo-map.js
generated
5
public/doxa-maps/demo-map/demo-map/index.html
generated
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../demo-map.js" integrity="sha384-3Yb2aEbNtFMFCI+I0oo1qCkx1HeP+oOpeOCLEPXpnZ3tNMqDSpxfWuv++nrmXgRc" crossorigin="anonymous"></script>
|
||||
<script src="../demo-map.js" integrity="sha384-6EtnK2myYmXQk3f9OCWyV20u8BnCdGD/HxPu3ZzM6tmpnRIM/AlRQeqk0dsH/b18" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"demo-map","dataSource":"pray-tools","instanceId":"demo-map-demo-map","assetsBase":"../../../assets"};
|
||||
// MapLibre pages need NO token: the ladder below is skipped entirely.
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = false;
|
||||
var TOKEN_SRC = "https://doxa.life/api/maps/token";
|
||||
var BAKED_TOKEN = "";
|
||||
|
|
|
|||