Replace poster-wall's Download/Prayer-List buttons with three real print actions
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
Driver, 2026-09-23: the UPG-100 list card should offer three PRINT options, each opening the browser's own print dialog on a rendered preview (never a silent file download), in this order: Print a UPG-100 list (the point of the app), Print a UPG prayer poster, Print a UPG card deck. - printList100() and printCardDeck() reuse printPoster()'s print-preview window mechanism (extracted to openPrintWindow()) rather than inventing a second one. - Both fetch real per-people-group records via the same normalize.js + selector-evaluator.js pipeline the sibling map profile already uses, so the map, the list print and the card deck can never disagree about which PGs belong to a list. Found and fixed along the way: 12 of 27 lists carry a `pg_slugs` selector that selector-evaluator.js intentionally throws on (a documented special case in upg-100-list.vue) — my first pass called it unconditionally, which silently ate every click on the India/China lists before reaching window.open(). Caught only by driving a real click and polling the HUD, not by reading the code. - Card deck is new code (no card/deck generator exists in this repo or in root-dx-life to port — searched first); one card per people group, print-paginated with `break-after:page`, styled after the poster-wall's own tape/card vocabulary. - Removed the now-orphaned exportPoster()/downloadPacket()/download() — the raw Download button and the "Prayer List: coming soon" placeholder were exactly the two buttons the Driver objected to. Verified live: instrumented window.open/window.print via Playwright, clicked all three buttons for real. Each opens exactly one window and calls print() exactly once (List: 1/1, Card Deck: 1/1; Poster: 1 window opened, print() correctly does NOT fire when the full-res poster 404s in this default, bundlePosters:false tree — the documented degraded state, confirmed by screenshot).
This commit is contained in:
parent
873a99d1a6
commit
2772486663
5 changed files with 275 additions and 104 deletions
|
|
@ -43,6 +43,13 @@ import { useViewportScrim } from '@map/utils/viewportScrim.js'
|
|||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// PORTED from the sibling map profile (upg-100-list.vue) rather than re-invented — same
|
||||
// normalize+evaluate pipeline it already uses to turn the live /people-groups/list API
|
||||
// payload + a list's `selector` into the exact PG rows that belong to that list. This is
|
||||
// the per-people-group data the UPG-100 LIST print and the CARD DECK print both need;
|
||||
// clusters.json itself only carries list-level rows (id/label/count/selector/bbox).
|
||||
import { normalizeRecords, extractRecords } from './data/normalize.js'
|
||||
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
|
||||
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
|
|
@ -66,6 +73,14 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
|
|||
// shows "Poster not available yet" via the img onerror).
|
||||
const posterUrl = (id) => `${assetsBase.value}/posters/${id}.webp`
|
||||
|
||||
// Config-only, fail-closed API base — SAME resolution as the sibling map profile
|
||||
// (upg-100-list.vue:297). No default host: a dev harness that never sets
|
||||
// window.MAP_APP_API_URL simply gets no PG-level data, and the two print paths
|
||||
// below degrade honestly (see printList100Notice / printCardDeckNotice).
|
||||
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|
||||
|| import.meta.env.VITE_API_BASE_URL
|
||||
|| ''
|
||||
|
||||
// ─── REGIONAL SECTIONS ───────────────────────────────────────────────────────
|
||||
// clusters.json carries no region field, so the mapping lives here. It is keyed by
|
||||
// list id rather than derived from the selector: a selector can be a wagf_block, a
|
||||
|
|
@ -250,54 +265,57 @@ const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canv
|
|||
const exportState = ref(null) // { label, phase, message, pct, kind }
|
||||
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
|
||||
|
||||
async function exportPoster(list) {
|
||||
if (exportState.value) return // one at a time
|
||||
// ── SERVE THE PRE-RENDERED POSTER — do NOT re-render ────────────────────────
|
||||
// This used to call useMapPoster(), which spins up a hidden Mapbox map and
|
||||
// captures its canvas. That produced a DIFFERENT ARTEFACT from the preview on
|
||||
// this very card: a bare basemap capture with none of the template — no title,
|
||||
// no scripture bands, no facts table, no numbered pins, no leader lines.
|
||||
//
|
||||
// Brief: "whenever I click print map on mobile I get the exact same preview
|
||||
// map I'm looking at." Serving the pre-rendered image makes preview and
|
||||
// download identical BY CONSTRUCTION rather than by keeping two renderers in
|
||||
// sync — and it works on Android, where the Node generator cannot run at all.
|
||||
//
|
||||
// The file is a 1500px WebP (~217 KB, 5in at 300dpi) built by
|
||||
// library/scripts/posters/make-previews.py from the same 3000px master that
|
||||
// produced the thumbnail above.
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
|
||||
message: 'fetching poster', pct: 20 }
|
||||
try {
|
||||
const url = posterUrl(list.id)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`poster not found (${res.status})`)
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 }
|
||||
const blob = await res.blob()
|
||||
download(blob, `doxa-upg-100-${list.id}-poster.webp`)
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'saved', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140), pct: 0 }
|
||||
// exportPoster()/downloadPacket()/download() REMOVED (2026-09-23) — the Driver's brief was
|
||||
// explicit: three print buttons, each opening an in-browser print dialog, no silent file
|
||||
// download as the default action. A raw "Download" button and a "Prayer List: coming soon"
|
||||
// placeholder were exactly the two buttons he objected to. Printing to PDF (Save as PDF in
|
||||
// the OS print dialog that printList100/printPoster/printCardDeck now all open) covers the
|
||||
// "get a file" need without a fourth competing button. posterUrl()/exportState/the modal
|
||||
// all stay — printPoster below still uses posterUrl(), and all three print paths still
|
||||
// share the exportState HUD for progress/errors.
|
||||
|
||||
// ─── SHARED PRINT-WINDOW MECHANISM ────────────────────────────────────────────
|
||||
// ONE mechanism, reused by all three "Print …" actions (poster / list / card
|
||||
// deck) rather than three ad-hoc window.opens. Extracted from the poster path,
|
||||
// which already carries the load-bearing fix documented below.
|
||||
//
|
||||
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
|
||||
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
|
||||
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
|
||||
// below was UNREACHABLE FOR EVERY USER, and every click fell through to a raw-content fallback.
|
||||
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
|
||||
// the identical call without the flag -> a usable handle. I first attributed the null to this
|
||||
// headless box; it is not environmental, it is what the flag means.
|
||||
// The window we write into carries same-origin markup we generate ourselves, so the opener
|
||||
// reference it regains is our own — there is nothing hostile for `noopener` to protect against here.
|
||||
//
|
||||
// `bodyHtml` is the FULL <html> document string (caller owns <head>/<style>/<body>). The caller's
|
||||
// content is responsible for its own print trigger — pass `autoprint:true` to have this helper
|
||||
// append the `window.focus();window.print()` bootstrap (used by markup with no <img onload> of
|
||||
// its own to hang the call off).
|
||||
function openPrintWindow(title, bodyHtml, { autoprint = false } = {}) {
|
||||
const w = window.open('', '_blank', 'width=980,height=1100')
|
||||
if (!w) return null // popup blocked — caller decides the fallback
|
||||
w.document.open()
|
||||
w.document.write(bodyHtml)
|
||||
if (autoprint) {
|
||||
// Appended AFTER the caller's markup so it runs once the document is parsed —
|
||||
// matches the <img onload> timing the poster path already relies on.
|
||||
w.document.write(
|
||||
'<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print()},150)})</' + 'script>')
|
||||
}
|
||||
w.document.close()
|
||||
return w
|
||||
}
|
||||
|
||||
|
||||
function downloadPacket(list) {
|
||||
// NOT an error — this is unbuilt, not broken. It used to raise phase:'error',
|
||||
// which reads to a user as "something went wrong" rather than "not yet".
|
||||
// Brief: mark it Coming soon; the prayer-list design is still to be specified.
|
||||
exportState.value = { label: list.label, kind: 'Prayer List', phase: 'soon',
|
||||
message: 'Coming soon — the prayer list for each people group is in build.', pct: 0 }
|
||||
}
|
||||
|
||||
function download(blob, name) {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = name
|
||||
document.body.appendChild(a); a.click(); a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
|
||||
// Escape the handful of characters that break out of an HTML text node or a "..." attribute.
|
||||
// Print-window markup is built by string concatenation (no template-literal/backtick rule for
|
||||
// shadow CSS extends to every string-built document in this file for the same reason: one stray
|
||||
// character silently breaks the whole write()), so every field sourced from data (list labels, PG
|
||||
// names, country/religion labels) is routed through this before insertion.
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// PRINT a poster: open it in a print-friendly preview window and raise the browser's
|
||||
|
|
@ -315,26 +333,8 @@ async function printPoster(list) {
|
|||
// onload -> print() so the dialog opens on the poster the user is looking at. The
|
||||
// poster is a 3ft square print (see the template) → @page portrait, image scaled to
|
||||
// fit the sheet with a small margin.
|
||||
// ★ NO `noopener` HERE, AND THAT IS THE WHOLE FIX (2026-09-23). `noopener` makes window.open()
|
||||
// return null BY SPECIFICATION — severing the opener relationship is exactly what the flag does,
|
||||
// so there is no handle to hand back. With it, `w` was always null, the document.write preview
|
||||
// below was UNREACHABLE FOR EVERY USER, and every click fell through to the raw-image fallback:
|
||||
// a .webp in a tab, no print dialog. The card claimed "opens in-browser print preview + dialog".
|
||||
// MEASURED in a default (non-headless-flagged) browser: window.open('','_blank','noopener') -> null,
|
||||
// the identical call without the flag -> a usable handle. I first attributed the null to this
|
||||
// headless box; it is not environmental, it is what the flag means.
|
||||
// `noopener` STAYS on the fallback below, which only navigates to a URL and never needs a handle,
|
||||
// so the protection is kept exactly where it costs nothing. The window we write into carries
|
||||
// same-origin markup we generate ourselves, so the opener reference it regains is our own.
|
||||
const w = window.open('', '_blank', 'width=900,height=1000')
|
||||
if (!w) { // popup blocked → open the image tab
|
||||
window.open(url, '_blank', 'noopener')
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900); return
|
||||
}
|
||||
const title = `Doxa UPG-100 poster — ${String(list.label || list.id).replace(/[<>&]/g, '')}`
|
||||
w.document.open()
|
||||
w.document.write(
|
||||
const title = `Doxa UPG-100 poster — ${escapeHtml(list.label || list.id)}`
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
|
||||
'body{display:flex;align-items:center;justify-content:center}' +
|
||||
|
|
@ -344,7 +344,11 @@ async function printPoster(list) {
|
|||
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
|
||||
' onerror="document.body.innerHTML=\'<p style="font:16px system-ui;padding:24px">Poster not available yet.</p>\'">' +
|
||||
'</body></html>')
|
||||
w.document.close()
|
||||
if (!w) { // popup blocked → open the image tab
|
||||
window.open(url, '_blank', 'noopener')
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900); return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
|
|
@ -353,6 +357,167 @@ async function printPoster(list) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── PER-LIST PEOPLE-GROUP RECORDS ─────────────────────────────────────────────
|
||||
// Both new print actions (the UPG-100 LIST and the CARD DECK) need the actual
|
||||
// people-group rows that belong to ONE list, not just its {id,label,count}
|
||||
// summary. This fetches the live API once per click, normalizes it with the
|
||||
// SAME normalize.js the sibling map profile uses (flattens the {value,label}
|
||||
// fields), then narrows to this list's members via the SAME selector-evaluator
|
||||
// the map uses to paint the tapestry — so "what's in this list" can never
|
||||
// disagree between the map, the list print and the card deck.
|
||||
async function fetchListRecords(list) {
|
||||
if (!API_BASE) throw new Error('no API configured')
|
||||
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion'
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
|
||||
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
|
||||
const records = normalizeRecords(extractRecords(await resp.json()))
|
||||
// ★ pg_slugs BYPASSES the shared evaluator — MEASURED live (2026-09-23): 12 of the 27
|
||||
// lists (the entire India + China buckets) carry `{ type:'pg_slugs', values:[...] }`,
|
||||
// and selector-evaluator.js documents that it "intentionally THROWS" on that type
|
||||
// (upg-100-list.vue:1455-1461, "BUG-A FIX"). Calling evaluateSelector() unconditionally
|
||||
// threw on every India/China card, which silently ate the click (the async function's
|
||||
// catch surfaced the evaluator's own error text, and window.open() was never reached —
|
||||
// found by polling the exportState HUD after a real click during verification, not by
|
||||
// inspection: the button looked fine and clicked fine). Ported the SAME special-case
|
||||
// upg-100-list.vue already uses: pg_slugs matches slug ∈ selector.values directly;
|
||||
// every other selector type still goes through the shared evaluator so the map, the
|
||||
// list print and the card deck can never disagree about membership.
|
||||
const slugs = (list.selector && list.selector.type === 'pg_slugs')
|
||||
? new Set(list.selector.values || [])
|
||||
: evaluateSelector(list.selector, records)
|
||||
return records.filter((r) => r && slugs.has(r.slug))
|
||||
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
|
||||
}
|
||||
|
||||
// PRINT #1 (Driver's FIRST option, "that's what the whole app was about"): the UPG-100
|
||||
// LIST itself — the prayer list of up to 100 people groups this card represents, as a
|
||||
// print-paginated table (name / country / population / religion), opened straight into
|
||||
// the browser's print dialog. Same mechanism as printPoster(): a dedicated window,
|
||||
// document.write, print() on load — never a silent file download.
|
||||
async function printList100(list) {
|
||||
if (exportState.value) return
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'fetch',
|
||||
message: 'fetching people-group records', pct: 30 }
|
||||
try {
|
||||
const rows = await fetchListRecords(list)
|
||||
const title = `Doxa UPG-100 list — ${escapeHtml(list.label || list.id)}`
|
||||
const rowsHtml = rows.map((r) => (
|
||||
'<tr><td class="c-name">' + escapeHtml(r.name) + '</td>' +
|
||||
'<td>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</td>' +
|
||||
'<td class="c-num">' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</td>' +
|
||||
'<td>' + escapeHtml(r.religion_label || '—') + '</td>' +
|
||||
'<td class="c-pray">□</td></tr>'
|
||||
)).join('')
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>' +
|
||||
'@page{size:letter portrait;margin:14mm 12mm}' +
|
||||
'html,body{margin:0;background:#fff;color:#0f1419;' +
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
|
||||
'.hd{margin-bottom:14px}' +
|
||||
'.hd h1{margin:0 0 3px;font-size:18px}' +
|
||||
'.hd p{margin:0;font-size:11px;color:#5b6672}' +
|
||||
'table{width:100%;border-collapse:collapse;font-size:11px}' +
|
||||
'thead th{text-align:left;border-bottom:2px solid #0f1419;padding:5px 6px;' +
|
||||
'font-size:9.5px;text-transform:uppercase;letter-spacing:.05em}' +
|
||||
'tbody td{padding:5px 6px;border-bottom:1px solid #e3e6ea}' +
|
||||
'.c-name{font-weight:600}.c-num{font-variant-numeric:tabular-nums}' +
|
||||
'.c-pray{width:26px;text-align:center;font-size:13px}' +
|
||||
'tbody tr{break-inside:avoid}' +
|
||||
'@media print{.hd{margin-top:0}}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="hd"><h1>' + title + '</h1>' +
|
||||
'<p>' + rows.length + ' people groups — pray for each one by name (Luke 9:14). ' +
|
||||
'Check the □ as you pray.</p></div>' +
|
||||
'<table><thead><tr><th>People group</th><th>Country</th><th>Population</th>' +
|
||||
'<th>Religion</th><th></th></tr></thead><tbody>' + rowsHtml + '</tbody></table>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'UPG-100 List', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140) + (API_BASE ? '' :
|
||||
' (no API configured in this preview — set window.MAP_APP_API_URL)'), pct: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// PRINT #3: the CARD DECK — one prayer card PER PEOPLE GROUP in this list, laid out for
|
||||
// a real printer with `break-after:page` between cards so each card lands on its own
|
||||
// sheet (or, cut down, its own card). New code — no card/deck generator exists anywhere
|
||||
// in root-dx-life or in this repo's library/ to port (searched; only a per-COUNTRY
|
||||
// pre-baked PDF pointer exists there, `prayer-card-{country}-{lang}.pdf`, not a per-PG
|
||||
// generator). Styling below borrows the poster-wall's own vocabulary (tape header, thin
|
||||
// card border, Material Light palette) instead of inventing a fourth visual language.
|
||||
async function printCardDeck(list) {
|
||||
if (exportState.value) return
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'fetch',
|
||||
message: 'fetching people-group records', pct: 30 }
|
||||
try {
|
||||
const rows = await fetchListRecords(list)
|
||||
const title = `Doxa UPG prayer cards — ${escapeHtml(list.label || list.id)}`
|
||||
const cardsHtml = rows.map((r) => (
|
||||
'<section class="card">' +
|
||||
'<div class="tape">' + escapeHtml(list.label || list.id) + '</div>' +
|
||||
'<h2>' + escapeHtml(r.name) + '</h2>' +
|
||||
'<dl>' +
|
||||
'<dt>Country</dt><dd>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</dd>' +
|
||||
'<dt>Population</dt><dd>' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</dd>' +
|
||||
'<dt>Affinity bloc</dt><dd>' + escapeHtml((r.rop1 && r.rop1.label) || '—') + '</dd>' +
|
||||
'<dt>Religion</dt><dd>' + escapeHtml(r.religion_label || '—') + '</dd>' +
|
||||
'</dl>' +
|
||||
'<p class="verse">"Ask the Lord of the harvest to send out workers into his harvest field." — Matthew 9:38</p>' +
|
||||
'</section>'
|
||||
)).join('')
|
||||
const w = openPrintWindow(title,
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
|
||||
'<style>' +
|
||||
'@page{size:letter portrait;margin:10mm}' +
|
||||
'html,body{margin:0;background:#f3f3f1;color:#3b463d;' +
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
|
||||
// Screen preview: a responsive grid so the deck can be reviewed before printing.
|
||||
'.deck{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;padding:18px}' +
|
||||
'.card{position:relative;background:#fff;border:1px solid #d1d4cd;border-radius:6px;' +
|
||||
'padding:22px 18px 16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}' +
|
||||
'.tape{position:absolute;top:-9px;left:50%;transform:translateX(-50%);' +
|
||||
'background:#92b195;color:#1c2a1f;font-size:9px;font-weight:700;text-transform:uppercase;' +
|
||||
'letter-spacing:.06em;padding:3px 10px;border-radius:999px;white-space:nowrap}' +
|
||||
'.card h2{margin:6px 0 10px;font-size:16px;font-weight:700}' +
|
||||
'.card dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11.5px}' +
|
||||
'.card dt{color:#6d766e;font-weight:600}' +
|
||||
'.card dd{margin:0}' +
|
||||
'.card .verse{margin:12px 0 0;padding-top:10px;border-top:1px solid #d1d4cd;' +
|
||||
'font-size:10.5px;font-style:italic;color:#6d766e;line-height:1.4}' +
|
||||
// PRINT PAGINATION: one card per sheet. `break-after:page` on every card except
|
||||
// the last is what makes a "deck" out of a document instead of a wall of cards
|
||||
// shrunk to fit — the Driver's ask was "a real printer produces a usable deck".
|
||||
'@media print{' +
|
||||
'.deck{display:block;padding:0}' +
|
||||
'.card{box-shadow:none;border-radius:0;border:2px solid #3b463d;' +
|
||||
'break-after:page;page-break-after:always;height:100%;box-sizing:border-box}' +
|
||||
'.card:last-child{break-after:auto;page-break-after:auto}' +
|
||||
'}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="deck">' + cardsHtml + '</div>' +
|
||||
'</body></html>', { autoprint: true })
|
||||
if (!w) {
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
message: 'Pop-up blocked — allow pop-ups for this site and try again.', pct: 0 }
|
||||
return
|
||||
}
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
} catch (e) {
|
||||
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
|
||||
message: String(e?.message || e).slice(0, 140) + (API_BASE ? '' :
|
||||
' (no API configured in this preview — set window.MAP_APP_API_URL)'), pct: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function dismissExport() { exportState.value = null }
|
||||
|
||||
useShadowStyles(
|
||||
|
|
@ -570,13 +735,19 @@ useShadowStyles(
|
|||
a step for nothing. -->
|
||||
<fieldset class="tape pfs">
|
||||
<legend>Print Options</legend>
|
||||
<!-- PRINT = open the in-browser preview + print dialog (primary). DOWNLOAD =
|
||||
save the file (secondary). Prayer List = coming soon. -->
|
||||
<!-- Driver, 2026-09-23: "It should say PRINT... open up a print dialog inside of
|
||||
your browser, not bring you to a new page... render or generate that poster
|
||||
and then let you print it out to PDF or print it directly to a printer." All
|
||||
THREE open the SAME in-browser print-preview + dialog mechanism
|
||||
(openPrintWindow, shared by printList100/printPoster/printCardDeck) — no
|
||||
silent file download as a primary action. LIST leads because it is what the
|
||||
whole app is about (Driver's ordering). -->
|
||||
<button type="button" class="pbtn pbtn-print" @click="printList100(list)"
|
||||
:aria-label="`Print the ${list.label} UPG-100 list`">🖨 Print a UPG-100 list</button>
|
||||
<button type="button" class="pbtn pbtn-print" @click="printPoster(list)"
|
||||
:aria-label="`Preview and print the ${list.label} poster`">🖨 Print poster</button>
|
||||
<button type="button" class="pbtn" @click="exportPoster(list)"
|
||||
:aria-label="`Download the ${list.label} poster file`">Download</button>
|
||||
<button type="button" class="pbtn" @click="downloadPacket(list)">Prayer List</button>
|
||||
:aria-label="`Print the ${list.label} UPG prayer poster`">🖨 Print a UPG prayer poster</button>
|
||||
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
|
||||
:aria-label="`Print the ${list.label} UPG card deck`">🖨 Print a UPG card deck</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
58
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
58
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5P8QseUJZQ3XBbreJUDZ7xqi1Pvzd2c/Ns2GuXhK+6tjZ2tVSUM1gRiC6vdg6bSJ" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-4Msd7/FhwYfrRjJXy7bgnbvQcLaQ3p6kjFZAm9VgnoTeZxOu25E4v0IAvw1udho0" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue