Plugin Studio: a map-token control (paste a Mapbox public token, or point it at a token service)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Driver request, 2026-09-25: "add a spot on the studio for placing a Mapbox token, or setting the URL
for where the Mapbox token API is from, like doxa.life has an API token".

A 🔑 Map token button in the studio header opens a small panel:
- paste a Mapbox PUBLIC token (pk.…) — a secret sk.… token is refused, the page is public;
- or a token-service URL: the studio fetches it (JSON {token|tk|accessToken} or a bare string, the
  leaf pages' contract) and uses the pk.… it returns. The maps never read a token URL themselves, so
  the studio resolves it and hands them the token;
- Clear returns to the token built into the page. A dot and a status line say which is in use.
Precedence: your token > a token from your URL > the host's tk > the page's built-in token. Kept in
this browser's localStorage only; applying re-mounts the open map and publishes window.MAP_TOKEN for
maps nested inside composites. Labels in all 11 locales (i18n parity PASS).

Tested in a browser on the built studio: shows the built-in source; sk.… refused and not stored; a
pasted pk.… reaches the pane's profile-config and window.MAP_TOKEN and survives a reload; Clear
restores the built-in token; a token URL is fetched and applied. https://doxa.life/api/maps/token is
refused cross-origin from another site — the panel says so and keeps the current token.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 18:26:55 +00:00
parent 816deb56a8
commit 966c44fcc0
18 changed files with 342 additions and 30 deletions

View file

@ -26,5 +26,18 @@
"expandList": "توسيع القائمة",
"collapseList": "طي القائمة",
"collapse": "طي",
"partOf": "جزء من {name}، غير منشور بمفرده"
"partOf": "جزء من {name}، غير منشور بمفرده",
"mapToken": "رمز الخريطة",
"tokenPaste": "رمز Mapbox العام (pk.…)",
"tokenUrlLabel": "…أو رابط خدمة الرموز",
"tokenApply": "استخدام",
"tokenClear": "مسح",
"tokenStoredHere": "يُحفظ في هذا المتصفح فقط.",
"tokenSourceYours": "يُستخدم رمزك",
"tokenSourceUrl": "يُستخدم رمز من {url}",
"tokenSourceBuilt": "يُستخدم الرمز المضمَّن في هذه الصفحة",
"tokenSourceNone": "لا يوجد رمز Mapbox: لا يمكن تحميل خرائط Mapbox (خرائط MapLibre لا تحتاج رمزًا)",
"tokenSecret": "هذا رمز سري (sk.…). الصق رمزًا عامًا (pk.…): هذه الصفحة عامة.",
"tokenNotPk": "يبدأ رمز Mapbox العام بـ pk.",
"tokenUrlFailed": "تعذّر الحصول على رمز من هذا الرابط: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Liste ausklappen",
"collapseList": "Liste einklappen",
"collapse": "Einklappen",
"partOf": "Teil von {name}, nicht eigenständig veröffentlicht"
"partOf": "Teil von {name}, nicht eigenständig veröffentlicht",
"mapToken": "Karten-Token",
"tokenPaste": "Öffentlicher Mapbox-Token (pk.…)",
"tokenUrlLabel": "…oder die URL eines Token-Dienstes",
"tokenApply": "Verwenden",
"tokenClear": "Löschen",
"tokenStoredHere": "Nur in diesem Browser gespeichert.",
"tokenSourceYours": "Ihr Token wird verwendet",
"tokenSourceUrl": "Token von {url} wird verwendet",
"tokenSourceBuilt": "Der in diese Seite eingebaute Token wird verwendet",
"tokenSourceNone": "Kein Mapbox-Token: Mapbox-Karten können nicht laden (MapLibre-Karten brauchen keinen)",
"tokenSecret": "Das ist ein geheimer Token (sk.…). Fügen Sie einen öffentlichen ein (pk.…): Diese Seite ist öffentlich.",
"tokenNotPk": "Ein öffentlicher Mapbox-Token beginnt mit pk.",
"tokenUrlFailed": "Von dieser URL kam kein Token: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Expand list",
"collapseList": "Collapse list",
"collapse": "Collapse",
"partOf": "part of {name}, not published on its own"
"partOf": "part of {name}, not published on its own",
"mapToken": "Map token",
"tokenPaste": "Mapbox public token (pk.…)",
"tokenUrlLabel": "…or the URL of a token service",
"tokenApply": "Use",
"tokenClear": "Clear",
"tokenStoredHere": "Kept in this browser only.",
"tokenSourceYours": "Using your token",
"tokenSourceUrl": "Using a token from {url}",
"tokenSourceBuilt": "Using the token built into this page",
"tokenSourceNone": "No Mapbox token: Mapbox maps cannot load (MapLibre maps need none)",
"tokenSecret": "That is a secret token (sk.…). Paste a public one (pk.…): this page is public.",
"tokenNotPk": "A Mapbox public token starts with pk.",
"tokenUrlFailed": "Could not get a token from that URL: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Expandir la lista",
"collapseList": "Contraer la lista",
"collapse": "Contraer",
"partOf": "parte de {name}, no publicada por separado"
"partOf": "parte de {name}, no publicada por separado",
"mapToken": "Token del mapa",
"tokenPaste": "Token público de Mapbox (pk.…)",
"tokenUrlLabel": "…o la URL de un servicio de tokens",
"tokenApply": "Usar",
"tokenClear": "Borrar",
"tokenStoredHere": "Se guarda solo en este navegador.",
"tokenSourceYours": "Usando tu token",
"tokenSourceUrl": "Usando un token de {url}",
"tokenSourceBuilt": "Usando el token integrado en esta página",
"tokenSourceNone": "Sin token de Mapbox: los mapas de Mapbox no cargan (los de MapLibre no lo necesitan)",
"tokenSecret": "Ese es un token secreto (sk.…). Pega uno público (pk.…): esta página es pública.",
"tokenNotPk": "Un token público de Mapbox empieza por pk.",
"tokenUrlFailed": "No se pudo obtener un token de esa URL: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Déplier la liste",
"collapseList": "Replier la liste",
"collapse": "Replier",
"partOf": "partie de {name}, non publiée seule"
"partOf": "partie de {name}, non publiée seule",
"mapToken": "Jeton de carte",
"tokenPaste": "Jeton public Mapbox (pk.…)",
"tokenUrlLabel": "…ou l’URL d’un service de jetons",
"tokenApply": "Utiliser",
"tokenClear": "Effacer",
"tokenStoredHere": "Conservé dans ce navigateur uniquement.",
"tokenSourceYours": "Votre jeton est utilisé",
"tokenSourceUrl": "Jeton obtenu de {url}",
"tokenSourceBuilt": "Jeton intégré à cette page",
"tokenSourceNone": "Aucun jeton Mapbox : les cartes Mapbox ne peuvent pas charger (MapLibre n’en a pas besoin)",
"tokenSecret": "C’est un jeton secret (sk.…). Collez un jeton public (pk.…) : cette page est publique.",
"tokenNotPk": "Un jeton public Mapbox commence par pk.",
"tokenUrlFailed": "Impossible d’obtenir un jeton depuis cette URL : {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "सूची फैलाएँ",
"collapseList": "सूची संक्षिप्त करें",
"collapse": "संक्षिप्त करें",
"partOf": "{name} का भाग, अलग से प्रकाशित नहीं"
"partOf": "{name} का भाग, अलग से प्रकाशित नहीं",
"mapToken": "मानचित्र टोकन",
"tokenPaste": "Mapbox सार्वजनिक टोकन (pk.…)",
"tokenUrlLabel": "…या किसी टोकन सेवा का URL",
"tokenApply": "उपयोग करें",
"tokenClear": "साफ़ करें",
"tokenStoredHere": "केवल इसी ब्राउज़र में रखा जाता है।",
"tokenSourceYours": "आपका टोकन उपयोग हो रहा है",
"tokenSourceUrl": "{url} से टोकन उपयोग हो रहा है",
"tokenSourceBuilt": "इस पेज में बना टोकन उपयोग हो रहा है",
"tokenSourceNone": "कोई Mapbox टोकन नहीं: Mapbox मानचित्र लोड नहीं हो सकते (MapLibre को इसकी ज़रूरत नहीं)",
"tokenSecret": "यह एक गुप्त टोकन है (sk.…)। सार्वजनिक टोकन (pk.…) चिपकाएँ: यह पेज सार्वजनिक है।",
"tokenNotPk": "Mapbox सार्वजनिक टोकन pk. से शुरू होता है",
"tokenUrlFailed": "उस URL से टोकन नहीं मिल सका: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Espandi l’elenco",
"collapseList": "Comprimi l’elenco",
"collapse": "Comprimi",
"partOf": "parte di {name}, non pubblicata da sola"
"partOf": "parte di {name}, non pubblicata da sola",
"mapToken": "Token mappa",
"tokenPaste": "Token pubblico Mapbox (pk.…)",
"tokenUrlLabel": "…o l’URL di un servizio di token",
"tokenApply": "Usa",
"tokenClear": "Cancella",
"tokenStoredHere": "Conservato solo in questo browser.",
"tokenSourceYours": "Uso il tuo token",
"tokenSourceUrl": "Uso un token da {url}",
"tokenSourceBuilt": "Uso il token integrato in questa pagina",
"tokenSourceNone": "Nessun token Mapbox: le mappe Mapbox non si caricano (MapLibre non ne ha bisogno)",
"tokenSecret": "Questo è un token segreto (sk.…). Incolla un token pubblico (pk.…): questa pagina è pubblica.",
"tokenNotPk": "Un token pubblico Mapbox inizia con pk.",
"tokenUrlFailed": "Impossibile ottenere un token da quell’URL: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Expandir a lista",
"collapseList": "Recolher a lista",
"collapse": "Recolher",
"partOf": "parte de {name}, não publicada separadamente"
"partOf": "parte de {name}, não publicada separadamente",
"mapToken": "Token do mapa",
"tokenPaste": "Token público do Mapbox (pk.…)",
"tokenUrlLabel": "…ou o URL de um serviço de tokens",
"tokenApply": "Usar",
"tokenClear": "Limpar",
"tokenStoredHere": "Guardado apenas neste navegador.",
"tokenSourceYours": "A usar o seu token",
"tokenSourceUrl": "A usar um token de {url}",
"tokenSourceBuilt": "A usar o token incorporado nesta página",
"tokenSourceNone": "Sem token do Mapbox: os mapas Mapbox não carregam (os MapLibre não precisam)",
"tokenSecret": "Esse é um token secreto (sk.…). Cole um público (pk.…): esta página é pública.",
"tokenNotPk": "Um token público do Mapbox começa por pk.",
"tokenUrlFailed": "Não foi possível obter um token desse URL: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Extinde lista",
"collapseList": "Restrânge lista",
"collapse": "Restrânge",
"partOf": "parte din {name}, nepublicată separat"
"partOf": "parte din {name}, nepublicată separat",
"mapToken": "Token hartă",
"tokenPaste": "Token public Mapbox (pk.…)",
"tokenUrlLabel": "…sau adresa URL a unui serviciu de tokenuri",
"tokenApply": "Folosește",
"tokenClear": "Șterge",
"tokenStoredHere": "Păstrat doar în acest browser.",
"tokenSourceYours": "Se folosește tokenul tău",
"tokenSourceUrl": "Se folosește un token de la {url}",
"tokenSourceBuilt": "Se folosește tokenul inclus în această pagină",
"tokenSourceNone": "Niciun token Mapbox: hărțile Mapbox nu se pot încărca (MapLibre nu are nevoie)",
"tokenSecret": "Acesta este un token secret (sk.…). Lipește unul public (pk.…): pagina este publică.",
"tokenNotPk": "Un token public Mapbox începe cu pk.",
"tokenUrlFailed": "Nu s-a putut obține un token de la acea adresă: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "Развернуть список",
"collapseList": "Свернуть список",
"collapse": "Свернуть",
"partOf": "часть {name}, не публикуется отдельно"
"partOf": "часть {name}, не публикуется отдельно",
"mapToken": "Токен карты",
"tokenPaste": "Публичный токен Mapbox (pk.…)",
"tokenUrlLabel": "…или адрес службы токенов",
"tokenApply": "Применить",
"tokenClear": "Очистить",
"tokenStoredHere": "Хранится только в этом браузере.",
"tokenSourceYours": "Используется ваш токен",
"tokenSourceUrl": "Используется токен с {url}",
"tokenSourceBuilt": "Используется токен, встроенный в страницу",
"tokenSourceNone": "Нет токена Mapbox: карты Mapbox не загрузятся (MapLibre он не нужен)",
"tokenSecret": "Это секретный токен (sk.…). Вставьте публичный (pk.…): эта страница общедоступна.",
"tokenNotPk": "Публичный токен Mapbox начинается с pk.",
"tokenUrlFailed": "Не удалось получить токен по этому адресу: {why}"
}

View file

@ -26,5 +26,18 @@
"expandList": "展开列表",
"collapseList": "折叠列表",
"collapse": "折叠",
"partOf": "{name} 的一部分,不单独发布"
"partOf": "{name} 的一部分,不单独发布",
"mapToken": "地图令牌",
"tokenPaste": "Mapbox 公开令牌 (pk.…)",
"tokenUrlLabel": "…或令牌服务的网址",
"tokenApply": "使用",
"tokenClear": "清除",
"tokenStoredHere": "仅保存在此浏览器中。",
"tokenSourceYours": "正在使用您的令牌",
"tokenSourceUrl": "正在使用来自 {url} 的令牌",
"tokenSourceBuilt": "正在使用此页面内置的令牌",
"tokenSourceNone": "没有 Mapbox 令牌:Mapbox 地图无法加载(MapLibre 地图不需要)",
"tokenSecret": "这是秘密令牌 (sk.…)。请粘贴公开令牌 (pk.…):此页面是公开的。",
"tokenNotPk": "Mapbox 公开令牌以 pk. 开头",
"tokenUrlFailed": "无法从该网址获取令牌:{why}"
}

View file

@ -83,6 +83,31 @@
app-wrapper-material/languages.js (persisted, offline, no dep) — same switcher as the SPA. -->
<template #meta>
<span class="ps-count">{{ countLabel }}</span>
<!-- MAP TOKEN (Driver request 2026-09-25): paste a Mapbox public token, or point the studio at a
token service (e.g. https://doxa.life/api/maps/token). Kept in THIS browser only; every map
pane (and every map nested inside a composite) is re-mounted with it. -->
<span class="ps-token">
<button type="button" class="ps-locale-btn ps-token-btn" :aria-expanded="String(tokenOpen)"
:title="tokenStatus" @click="tokenOpen = !tokenOpen">
<span aria-hidden="true">🔑</span>
<span class="ps-locale-name">{{ tr('mapToken') }}</span>
<span class="ps-token-dot" :class="'is-' + tokenSource" aria-hidden="true"></span>
</button>
<form v-if="tokenOpen" class="ps-token-panel" @submit.prevent="applyToken">
<label class="ps-token-label">{{ tr('tokenPaste') }}
<input class="ps-token-input" v-model.trim="tokenDraft" placeholder="pk.eyJ…" autocomplete="off" spellcheck="false" />
</label>
<label class="ps-token-label">{{ tr('tokenUrlLabel') }}
<input class="ps-token-input" v-model.trim="urlDraft" type="url" placeholder="https://doxa.life/api/maps/token" autocomplete="off" spellcheck="false" />
</label>
<div class="ps-token-row">
<button type="submit" class="ps-token-apply" :disabled="tokenBusy">{{ tr('tokenApply') }}</button>
<button type="button" class="ps-token-clear" @click="clearToken">{{ tr('tokenClear') }}</button>
</div>
<p class="ps-token-status" :class="{ err: !!tokenErr }" role="status">{{ tokenErr || tokenStatus }}</p>
<p class="ps-token-note">{{ tr('tokenStoredHere') }}</p>
</form>
</span>
<span class="ps-locale">
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
:aria-label="tr('changeLanguage')" @click="localeOpen = !localeOpen">
@ -163,6 +188,13 @@ import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import StudioTree from './src/StudioTree.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
// The reader's own map token / token-service URL. localStorage only: never written into the build,
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
const TOKEN_KEY = 'plugin-studio:mapbox-token'
const TOKEN_URL_KEY = 'plugin-studio:mapbox-token-url'
function readStored(k) { try { return (typeof window !== 'undefined' && window.localStorage.getItem(k)) || '' } catch (_) { return '' } }
function writeStored(k, v) { try { if (v) window.localStorage.setItem(k, v); else window.localStorage.removeItem(k) } catch (_) { /* storage unavailable */ } }
// 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).
@ -172,6 +204,21 @@ const PS_SHADOW_CSS = `
.ps-logo { display:inline-flex; align-items:center; }
.ps-logo svg { display:block; }
.ps-count { color:#6d766e; }
.ps-token { position:relative; display:inline-flex; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
.ps-token-dot.is-built { background:#c9b36a; }
.ps-token-dot.is-none { background:#d27a6a; }
.ps-token-panel { position:absolute; top:100%; inset-inline-end:0; margin-top:4px; z-index:30; width:min(340px, 90vw); display:flex; flex-direction:column; gap:8px; padding:12px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.25); color:#3b463d; font-size:12px; }
.ps-token-label { display:flex; flex-direction:column; gap:4px; }
.ps-token-input { font:inherit; font-size:12px; padding:6px 8px; border:1px solid #d1d4cd; border-radius:7px; background:#f8f8f6; color:#3b463d; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; }
.ps-token-row { display:flex; gap:8px; }
.ps-token-apply, .ps-token-clear { font:inherit; font-size:12px; padding:5px 12px; border-radius:7px; border:1px solid #d1d4cd; background:#d1d4cd; color:#3b463d; cursor:pointer; }
.ps-token-apply { background:#92b195; border-color:#92b195; color:#fff; }
.ps-token-apply:disabled { opacity:.6; cursor:progress; }
.ps-token-status { margin:0; }
.ps-token-status.err { color:#b3412f; }
.ps-token-note { margin:0; color:#6d766e; }
.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 #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; }
.ps-locale-btn:hover { background:#d1d4cd; }
@ -417,6 +464,16 @@ export default defineComponent({
// A host- or URL-given locale is NOT persisted: it is the host's choice, not the reader's.
locale: this.initialLocale(),
localeOpen: false,
// Map token control — the reader's own entry, persisted in localStorage (never in the build).
tokenOpen: false,
tokenBusy: false,
tokenErr: '',
tokenDraft: readStored(TOKEN_KEY),
urlDraft: readStored(TOKEN_URL_KEY),
userTk: readStored(TOKEN_KEY),
userTokenUrl: readStored(TOKEN_URL_KEY),
fetchedTk: '',
bakedTk: '',
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`)
@ -555,6 +612,24 @@ export default defineComponent({
// disagreeing by one, with nothing on either saying why, is how a reader stops trusting both.
// "listed here" is the whole fix: it names the population, so the difference from the catalog
// total reads as a deliberate exclusion rather than an off-by-one.
// The token every pane gets, in precedence order: the reader's pasted token, a token fetched from
// the reader's token URL, a token the HOST passed (tk attribute / profile-config), and last the
// token built into the page (window.MAP_TOKEN, baked at build time).
effectiveTk() {
return this.userTk || this.fetchedTk || this.tk || this.cfg.tk || this.bakedTk || ''
},
tokenSource() {
if (this.userTk) return 'yours'
if (this.fetchedTk) return 'url'
if (this.tk || this.cfg.tk || this.bakedTk) return 'built'
return 'none'
},
tokenStatus() {
if (this.tokenSource === 'yours') return this.tr('tokenSourceYours')
if (this.tokenSource === 'url') return this.tr('tokenSourceUrl', { url: this.userTokenUrl })
if (this.tokenSource === 'built') return this.tr('tokenSourceBuilt')
return this.tr('tokenSourceNone')
},
countLabel() {
const nb = new Set(this.rows.map(r => r.bundle)).size
return this.tr('countLabel', { bundles: nb, elements: this.rows.length })
@ -566,9 +641,73 @@ export default defineComponent({
uiDir() { return (this.activeLang && this.activeLang.dir) === 'rtl' ? 'rtl' : 'ltr' }
},
mounted() {
// Read once: after applyToken sets window.MAP_TOKEN it would no longer be "the page's own".
this.bakedTk = (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
if (this.userTk) this.publishToken()
else if (this.userTokenUrl) this.fetchToken(this.userTokenUrl).then((t) => { if (t) { this.fetchedTk = t; this.publishToken() } }).catch(() => {})
this.loadManifest()
},
methods: {
// ── MAP TOKEN ────────────────────────────────────────────────────────────────────────────────
// A token service answers with JSON ({token|tk|accessToken}) or a bare string — the same contract
// the leaf pages' token ladder accepts. Only a PUBLIC token (pk.…) is ever used: this page is public.
async fetchToken(url) {
if (!/^https?:\/\//i.test(url)) throw new Error('not an http(s) URL')
let res
try { res = await fetch(url, { cache: 'no-store', credentials: 'omit' }) } catch (e) {
throw new Error('the request was blocked or the server did not answer (cross-origin rules can do this)')
}
if (!res.ok) throw new Error('HTTP ' + res.status)
const text = (await res.text()).trim()
let tok = text
try { const j = JSON.parse(text); tok = (j && (j.token || j.tk || j.accessToken)) || (typeof j === 'string' ? j : '') } catch { /* a bare token string */ }
tok = String(tok || '').trim()
if (tok.startsWith('sk.')) throw new Error(this.tr('tokenSecret'))
if (!tok.startsWith('pk.')) throw new Error(this.tr('tokenNotPk'))
return tok
},
async applyToken() {
this.tokenErr = ''
const tk = this.tokenDraft, url = this.urlDraft
if (tk.startsWith('sk.')) { this.tokenErr = this.tr('tokenSecret'); return }
if (tk && !tk.startsWith('pk.')) { this.tokenErr = this.tr('tokenNotPk'); return }
this.tokenBusy = true
try {
let fetched = ''
if (!tk && url) {
try { fetched = await this.fetchToken(url) } catch (e) { this.tokenErr = this.tr('tokenUrlFailed', { why: e.message }); return }
}
this.userTk = tk
this.userTokenUrl = tk ? '' : url
this.fetchedTk = fetched
writeStored(TOKEN_KEY, this.userTk)
writeStored(TOKEN_URL_KEY, this.userTokenUrl)
this.publishToken()
this.remountPanes()
this.tokenOpen = false
} finally { this.tokenBusy = false }
},
clearToken() {
this.tokenDraft = ''; this.urlDraft = ''; this.tokenErr = ''
this.userTk = ''; this.userTokenUrl = ''; this.fetchedTk = ''
writeStored(TOKEN_KEY, ''); writeStored(TOKEN_URL_KEY, '')
this.publishToken()
this.remountPanes()
},
// Maps nested INSIDE a composite read window.MAP_TOKEN rather than their parent's profile-config,
// so the effective token is published there too (and the page's own token restored on clear).
publishToken() {
if (typeof window === 'undefined') return
if (this.effectiveTk) window.MAP_TOKEN = this.effectiveTk
else if ('MAP_TOKEN' in window) delete window.MAP_TOKEN
},
// Release every mounted pane (frees its map) and re-mount the one on screen with the new token;
// the others pick it up when they are next selected.
remountPanes() {
for (const row of this.rows) if (row.mounted) this.releaseRow(row)
const active = this.active
if (active) this.$nextTick(() => { if (this.activeKey === active.key) this.mountElement(active) })
},
// Localised studio string: current locale → English → the key itself; {name}-style placeholders.
tr(key, vars) {
const base = (this.locale || 'en').split('-')[0]
@ -603,7 +742,7 @@ export default defineComponent({
// and treeBase, so switching language flipped a maplibre pane back to mapbox.
paneConfig(row, locale) {
const pc = { profile: row.profile, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale }
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
if (this.effectiveTk) pc.tk = this.effectiveTk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
const wantProvider = this.provider || this.cfg.provider
if (wantProvider === 'maplibre' || wantProvider === 'mapbox') pc.provider = wantProvider
@ -867,6 +1006,21 @@ export default defineComponent({
.ps-logo { display: inline-flex; align-items: center; }
.ps-logo svg { display: block; }
.ps-count { color: #6d766e; }
.ps-token { position:relative; display:inline-flex; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
.ps-token-dot.is-built { background:#c9b36a; }
.ps-token-dot.is-none { background:#d27a6a; }
.ps-token-panel { position:absolute; top:100%; inset-inline-end:0; margin-top:4px; z-index:30; width:min(340px, 90vw); display:flex; flex-direction:column; gap:8px; padding:12px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.25); color:#3b463d; font-size:12px; }
.ps-token-label { display:flex; flex-direction:column; gap:4px; }
.ps-token-input { font:inherit; font-size:12px; padding:6px 8px; border:1px solid #d1d4cd; border-radius:7px; background:#f8f8f6; color:#3b463d; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; }
.ps-token-row { display:flex; gap:8px; }
.ps-token-apply, .ps-token-clear { font:inherit; font-size:12px; padding:5px 12px; border-radius:7px; border:1px solid #d1d4cd; background:#d1d4cd; color:#3b463d; cursor:pointer; }
.ps-token-apply { background:#92b195; border-color:#92b195; color:#fff; }
.ps-token-apply:disabled { opacity:.6; cursor:progress; }
.ps-token-status { margin:0; }
.ps-token-status.err { color:#b3412f; }
.ps-token-note { margin:0; color:#6d766e; }
.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 #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; }
.ps-locale-btn:hover { background: #d1d4cd; }

View file

@ -782,7 +782,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA",
"integrity": "sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd",
"elements": [
"plugin-studio"
],
@ -810,7 +810,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{

View file

@ -14,6 +14,6 @@
</head>
<body>
<plugin-studio tree-base="."></plugin-studio>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA" crossorigin="anonymous"></script>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -782,7 +782,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "doxa-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA",
"integrity": "sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd",
"elements": [
"plugin-studio"
],
@ -810,7 +810,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-JkHKL07MX7U5S8CLVSqL5FoDxuS25rpJygq75ozwNJuq8w9TVeZCw5DCZw82CTTA" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-iwq2RhhEc/Rx5dfEVoxYtxYjBd+1w47smMC8IDs0VglbZBt9RqwzrFu0SOuPGrBd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: