doxa-maps/plugin-designs/poster-design/poster-styles.vue
Doxa Maps e043402006 Poster Design: production designs first; experimental ones in a collapsed group
Styles now lists the production designs by family, and puts every
experimental design last, in one Experimental (in progress) group that
starts collapsed (a click opens it). Experimental: the side-lane country
sheets (66x66, 36x60, and 60x96, still being designed), the map print (no
preview: it prints the live map), and every other plugin's design (made by
its own plugin, so not drawn here). templates.js marks them experimental;
posterDesign.family.experimental is new in all 11 locales.
2026-10-03 04:19:37 +00:00

263 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* poster-styles.vue: every poster template the library prints, one card per configuration.
*
* @element poster-design
* @description Every poster template in one grid (map sheets, the prayer packet, prayer cards, the wall
* panels, the map print), each card drawn from the library with sample data and named by its
* configuration, with "Show full details" for what an agent needs to reuse it.
*
* The cards ARE the poster wall's cards: this profile renders the library's PosterWall and changes two
* things through its host seams. headerAction turns the card's "Show on map" sticker into "Show full
* details" (there is no map here), and previewSrc draws each thumbnail from the library at view time
* instead of reading a stored image, so a change to a template shows up here the next time it opens.
* The catalogue itself is src/templates.js. Below it, every poster design OTHER plugins declare in their own meta
* ("posterDesign", carried into manifest.json; src/pluginDesigns.js) gets a card in its own group: it is made by its
* own plugin, so the card says so and "Show full details" shows that plugin's page live.
*/
import { computed, inject, onMounted, ref } from 'vue'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { absoluteEmbedUrl, resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import countryData from '@map/data/country-posters.json'
import { makeCountrySheet, sheetWithheld } from '@map/packet/countrySheet.js'
import { loadPacketStrings } from '@map/packet/packetStrings.js'
import { firstPageImage } from './src/pageThumb.js'
import { panelsSvgUrl } from './src/panelsSvg.js'
import { resolvePosterSize } from '@map/constants/posterSizes.js'
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
import FullDetailsDialog from './src/FullDetailsDialog.vue'
import { loadPluginDesigns } from './src/pluginDesigns.js'
import { sizeText, declaredSizeText } from './src/sizeText.js'
const profileConfig = inject('profileConfig', ref(null))
const instanceId = computed(() => profileConfig.value?.instanceId || 'poster-design-styles')
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
const t = useOptionalT('')
const configs = listConfigurations()
const byName = new Map(configs.map((c) => [c.name, c]))
// A card's title: the template's name, then its size. Card stocks name the stock the way the wall's own
// stock picker does (the noun translates, the size is printed as a printer's box prints it).
function stockLabel(s) {
if (!s.noun) return s.label
const noun = s.noun === 'postcard' ? t('wall.stockPostcard') : t('wall.stockCard')
const dims = `${s.w} × ${s.h} in`
return s.side ? `${noun} ${dims} (${t('wall.stockLandscape')})` : `${noun} ${dims}`
}
const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${sizeText(c.size, t)}`)
// Designs other plugins declare (their meta's posterDesign, via manifest.json): the built tree's manifest, or the dev
// server's. A name already in templates.js stays the library's (the check refuses the clash; the page never doubles it).
const pluginDesigns = ref([])
const byPluginName = computed(() => new Map(pluginDesigns.value.map((d) => [d.name, d])))
onMounted(async () => {
const urls = [absoluteEmbedUrl('manifest.json', profileConfig.value), import.meta.env.DEV ? '/__plugin-gallery/manifest.json' : '']
const { designs } = await loadPluginDesigns(urls)
pluginDesigns.value = designs.filter((d) => !byName.has(d.name))
})
// The design's title in the reader's language: the build's per-locale titles (posterDesignLabels; any catalogue
// area), then this bundle's own catalogue, then English, then the profile's label.
const pluginTitle = (d) => {
const lang = String(getRuntimeLocale() || detectLocale() || 'en').split('-')[0]
if (d.titles && d.titles[lang]) return d.titles[lang]
const v = d.labelKey ? t(d.labelKey) : ''
if (v && v !== d.labelKey) return v
return (d.titles && d.titles.en) || d.label
}
const pluginLabel = (d) => {
return `${pluginTitle(d)}: ${declaredSizeText(d.size, t)}`
}
// PRODUCTION designs first, by family. EXPERIMENTAL ones (marked in templates.js: still being designed, or no
// preview) and every other plugin's design (made by its own plugin, so drawn nowhere here) go last, in ONE
// Experimental group that starts collapsed.
const isExperimental = (id) => byPluginName.value.has(id) || byName.get(id)?.experimental === true
const experimentalHeading = computed(() => t('posterDesign.family.experimental'))
// No `count`: these are templates, not lists of people, so the sticker reads "Show full details" alone.
const items = computed(() => {
const all = [
...configs.map((c) => ({ id: c.name, label: labelOf(c) })),
...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })),
]
return [...all.filter((x) => !isExperimental(x.id)), ...all.filter((x) => isExperimental(x.id))]
})
const groupBy = (item) => (isExperimental(item.id) ? experimentalHeading.value : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet))
const collapsedBy = (sec) => (sec && sec.name) === experimentalHeading.value // PosterWall passes the section
// ── drawing a configuration from the library ──────────────────────────────────────────────────
const countryById = (id) => (countryData.countries || []).find((c) => c.id === id)
// A design whose sample country is WITHHELD (countrySheet.js: the whole-India poster, for now) shows this sentence on its
// card and is never drawn or printed: PosterWall answers `unsupported` before it draws a preview or starts a print. The
// designs stay in the catalogue, so they reappear the day the country is no longer withheld.
// A design made by another plugin is not drawn here either: its card says so, and its details show its page live.
const withheld = (item) => {
if (byPluginName.value.has(item.id)) return t('posterDesign.fromPlugin')
const c = byName.get(item.id)
const sample = c && c.sample ? countryById(c.sample) : null
return sample && sheetWithheld(sample) ? t('messages.countryPosterTooMany') : null
}
const sample = countryById(SAMPLE_COUNTRY_ID)
const recordsFor = createPeopleGroupRecords({
apiBase: () => profileConfig.value?.apiBaseUrl || getApiBaseUrl(),
lang: () => getRuntimeLocale() || detectLocale(),
profileConfig,
})
// The ONE path every surface draws a country sheet through (the Country Posters wall, the Regions tab),
// so the sample carries its region hue and its "map N of M" exactly as the wall prints it.
const sheetFor = makeCountrySheet({ records: recordsFor, assetsBase: () => assetsBase.value, countries: countryData.countries || [] })
// A country's sheet at a format, through the shared path; the large side-lane sheets use their own sample (India).
const countrySheet = (format, id = SAMPLE_COUNTRY_ID) => {
const c = countryById(id)
return c ? sheetFor(c, format) : Promise.resolve('')
}
// The sample's people groups and the packet's words, each fetched once for the whole gallery.
const lang = () => getRuntimeLocale() || detectLocale() || 'en'
let rowsP = null
const sampleRows = () => (rowsP ||= (sample ? recordsFor({ type: 'country', values: [sample.iso3] }) : Promise.resolve([]))
.then((r) => r || []).catch(() => { rowsP = null; return [] }))
let stringsP = null
const packetStrings = () => (stringsP ||= loadPacketStrings(assetsBase.value))
// A card deck exactly as the wall prints one (usePosterPrints printCardDeck: createCardRenderer(...).buildDeck):
// the whole deck is the document, its first card the thumbnail.
async function cardDeck(stockId) {
const [rows, strings, { createCardRenderer }] = await Promise.all([sampleRows(), packetStrings(), import('@map/packet/card-render.js')])
if (!rows.length) return null
const R = createCardRenderer({ strings, lang: lang(), stock: stockId })
return { html: R.buildDeck(sample, rows), page: '.card', width: R.W, height: R.H }
}
// A packet's PAGE 1, its map page, from the same renderer and the same map sheet the wall's packet uses
// (the sample's map-only 'packet' sheet, lifted by the renderer). Only page 1 is built: it is what the
// thumbnail shows, and it needs nothing the later pages need (descriptions, the evangelical share).
async function packetDocument(variant) {
const [rows, strings, { createPacketRenderer }, sheetHtml] = await Promise.all([
sampleRows(), packetStrings(), import('@map/packet/packet-render.js'), countrySheet('packet'),
])
if (!rows.length) return null
const R = createPacketRenderer({ strings, lang: lang() })
const mapSvg = sheetHtml ? R.liftMap(sheetHtml) : ''
const doc = R.buildPacket(sample, rows, new Map(), mapSvg, null, null, variant, null)
return { html: doc, page: '.sheet', width: R.W, height: R.H, firstPageOnly: true }
}
// A multi-sheet poster as its panels, from the shared ladder's sheets for that size.
function panels(sizeKey) {
const size = resolvePosterSize(sizeKey)
return size ? { image: panelsSvgUrl(size.sheets) } : null
}
const ctx = { countrySheet, cardDeck, packetDocument, panels }
// The printed document's size, measured when it is drawn: what "file size" means in the details.
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
const fileBytes = ref({})
async function renderDocument(name) {
const c = byName.get(name)
if (!c || !c.render) return null
const out = await c.render(ctx)
if (out && out.image) return out
const html = typeof out === 'string' ? out : out && out.html
if (!html) return null
if (!(out && out.firstPageOnly)) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return out
}
// previewSrc: the thumbnail. A map sheet is rasterised at a quarter scale (a 1500px sheet becomes ~375px,
// the card's size), and a render that comes out blank is refused, so the card shows "preview unavailable"
// rather than a black rectangle. A document's first page is drawn as it prints (src/pageThumb.js).
async function previewSrc(item) {
const out = await renderDocument(item.id)
if (!out) return null
if (out.image) return out.image
if (typeof out === 'object') return firstPageImage(out.html, out)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(out, { scale: 0.25 })
if (!(await looksRendered(blob))) return null
return URL.createObjectURL(blob)
}
// The card's print button prints THIS configuration: the format the wall asks for is ignored on purpose,
// because the card already names its size. A card deck prints whole, as the wall prints it. A packet is
// built here only as far as its page 1, so it is NOT offered for printing from this card: printing a
// packet missing its later pages would misrepresent the template.
async function sheet(item) {
const out = await renderDocument(item.id)
if (!out || out.image || (typeof out === 'object' && out.firstPageOnly)) return ''
return typeof out === 'string' ? out : out.html
}
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
const open = ref(null)
const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(byName.get(open.value.name))))
// A plugin's design opens in the same dialog, as a configuration: its renderer is the plugin's page, and the dialog
// shows that page live.
function showDetails(item) {
const d = byPluginName.value.get(item.id)
open.value = d
? { name: d.name, size: declaredSizeText(d.size, t), settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) },
renderer: `plugin-designs/${d.bundle}/${d.profile}.vue`, pageUrl: d.pageUrl, design: d }
: byName.has(item.id) ? { ...byName.get(item.id), size: sizeText(byName.get(item.id).size, t) } : null
}
const heading = computed(() => t('posterDesign.styles.heading'))
// PosterWall renders the intro as HTML, so plain catalogue text is escaped first.
const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const intro = computed(() => escHtml(t('posterDesign.styles.intro')))
// Child components' <style> blocks are dropped by defineCustomElement, so this profile carries the
// dialog's CSS too. The floor sits on :host, where a shell that sizes its pane can override it.
useShadowStyles(`
:host{display:block;width:100%;height:100%;min-height:420px}
.pd-styles{position:absolute;inset:0;min-height:0}
.pd-dialog{box-sizing:border-box;border:1px solid #d1d4cd;border-radius:12px;padding:20px 22px;max-width:min(560px,calc(100vw - 32px));
width:100%;color:#3b463d;background:#fff;font:14px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
box-shadow:0 12px 40px rgba(8,12,16,.25)}
.pd-dialog::backdrop{background:rgba(8,12,16,.55)}
.pd-dialog__title{margin:0 0 14px;font-size:18px;font-weight:700;line-height:1.3}
.pd-dialog__list{display:grid;grid-template-columns:max-content 1fr;gap:8px 16px;margin:0}
.pd-dialog__list dt{color:#6d766e;font-weight:600}
.pd-dialog__list dd{margin:0;min-width:0;overflow-wrap:anywhere}
.pd-dialog__list code{font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f3f3f1;border-radius:4px;padding:1px 5px}
.pd-dialog__name{font-weight:700}
.pd-dialog__copy,.pd-dialog__close{font:inherit;border-radius:8px;border:1px solid #d1d4cd;background:#f3f3f1;color:#3b463d;
padding:4px 10px;cursor:pointer;margin-inline-start:8px}
.pd-dialog__note{margin:14px 0 0;color:#6d766e}
.pd-dialog__agent{margin:14px 0 0;padding:10px 12px;border-radius:8px;background:#f3f3f1;border-inline-start:4px solid #92b195}
.pd-dialog__actions{display:flex;justify-content:flex-end;margin:16px 0 0}
.pd-dialog__live{margin:14px 0 0}
.pd-dialog__frame{display:block;width:100%;height:min(60vh,520px);border:1px solid #d1d4cd;border-radius:8px;background:#f3f3f1}
.pd-dialog__open{display:inline-block;margin-top:8px;color:#3b463d;font-weight:600}
.pd-dialog__close{margin:0;background:#92b195;border-color:#92b195;color:#fff;font-weight:600;padding:6px 16px}
@media (max-width:480px){.pd-dialog__list{grid-template-columns:1fr}.pd-dialog__list dd{margin-bottom:6px}}
`, 'poster-design-styles')
</script>
<template>
<div class="pd-styles">
<PosterWall
:items="items"
:group-by="groupBy"
:instance-id="instanceId"
:assets-base="assetsBase"
:heading="heading"
:intro="intro"
unit=""
item-count-key="posterDesign.templateCount"
:collapsible="true"
:collapsed-by="collapsedBy"
:header-action="{ labelKey: 'posterDesign.showFullDetails', ariaKey: 'posterDesign.showFullDetailsAria' }"
:preview-src="previewSrc"
:unsupported="withheld"
:sheet="sheet"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-poster-design"
@header-action="showDetails"
/>
<FullDetailsDialog :config="open" :label="openLabel" :file-bytes="open ? (fileBytes[open.name] ?? null) : null" @close="open = null" />
</div>
</template>