251 lines
14 KiB
JavaScript
251 lines
14 KiB
JavaScript
// usePosterPrints.js — THE print actions every poster wall shares.
|
|
//
|
|
// ★ WHY THIS EXISTS. The poster wall was generalised into
|
|
// library/components/poster/PosterWall.vue and two bundles were migrated onto it —
|
|
// but the ORIGINAL, my-upg-100-list/poster-wall.vue (903 lines), was left behind.
|
|
// So the one wall carrying the real print actions is the one wall nothing else can
|
|
// benefit from, and the shared component's packet path is still a "coming soon"
|
|
// stub. Every print fix made in the 100-list is invisible to the three bundles on
|
|
// the shared component, and vice versa. That divergence is the bug.
|
|
//
|
|
// ── THE SEAM ────────────────────────────────────────────────────────────────
|
|
// The PRINT ACTIONS are shareable; the RECORD FETCHING is not. fetchListRecords in
|
|
// the 100-list knows the people-groups API, the selector evaluator and the pg_slugs
|
|
// special case; Key Cities and Country Posters have entirely different data shapes.
|
|
// So this composable takes a `records(item)` PROVIDER and owns everything after it:
|
|
// the HUD state machine, the in-place print frame, and the three documents.
|
|
//
|
|
// That is also what keeps the bundles honest — a wall that cannot supply records
|
|
// simply does not pass a provider, and the actions that need them stay hidden
|
|
// rather than failing at click time.
|
|
//
|
|
// ⚠ PRINTS IN PLACE, never window.open(). A popup is blockable and drops the reader
|
|
// out of the map; the shared openPrintFrame mounts a hidden same-page iframe. This
|
|
// is the mechanism the agent asked for by name: "not supposed to open up a new
|
|
// browser, but should open up the print dialog directly in the browser".
|
|
|
|
import { ref } from 'vue';
|
|
import { openPrintFrame } from './printInPlace.js';
|
|
|
|
const esc = (s) => String(s ?? '')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* records async (item) => rows[] — REQUIRED for the list and deck prints.
|
|
* strings () => Promise<object> — the packet/card i18n catalog.
|
|
* lang () => string — the app locale; the deck and packet follow it.
|
|
* stock () => string — card stock id (see card-render.js STOCK).
|
|
* posterUrl (item) => string|null — the pre-rendered image, when one exists.
|
|
* label (item) => string
|
|
*/
|
|
export function usePosterPrints(opts = {}) {
|
|
// ⚠ ACCEPT the caller's state ref when it has one. A wall that renders its OWN
|
|
// exportState while these actions write to a private ref shows no progress and
|
|
// swallows errors — the mechanism works and the feedback does not. Owning a ref
|
|
// here is only the standalone default.
|
|
const exportState = opts.state || ref(null);
|
|
const busy = () => exportState.value !== null;
|
|
|
|
const label = opts.label || ((item) => item?.label || item?.id || '');
|
|
const lang = opts.lang || (() => 'en');
|
|
const stock = opts.stock || (() => 'poker');
|
|
|
|
const set = (item, kind, phase, message, pct) => {
|
|
exportState.value = { label: label(item), kind, phase, message, pct };
|
|
};
|
|
const done = (item, kind) => {
|
|
set(item, kind, 'done', 'print dialog opened', 100);
|
|
setTimeout(() => { exportState.value = null; }, 900);
|
|
};
|
|
const fail = (item, kind, e) => {
|
|
set(item, kind, 'error', String(e?.message || e).slice(0, 140), 0);
|
|
};
|
|
function dismiss() { exportState.value = null; }
|
|
|
|
async function rows(item) {
|
|
if (typeof opts.records !== 'function') {
|
|
throw new Error('this wall supplies no records provider');
|
|
}
|
|
const r = await opts.records(item);
|
|
if (!r || !r.length) throw new Error('that list resolved to no records');
|
|
return r;
|
|
}
|
|
|
|
// ── 1. THE POSTER ─────────────────────────────────────────────────────────
|
|
// Prefers the pre-rendered image; falls back to rasterising the poster's own
|
|
// HTML in the browser, which is what lets the repo stop shipping 41 MB of webp.
|
|
// ★ THIS IS THE WALL'S posterAction='print' PATH, PROMOTED. It was a private function in
|
|
// PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to
|
|
// reach it — and the legend had grown a fourth implementation instead: a hand-rolled
|
|
// `<a download>` of the .webp. That was wrong twice over. It silently downloads a file,
|
|
// which the agent ruled out explicitly (2026-09-23: "no silent file download as the default
|
|
// action"), and it has NO fallback, so on the 27 lists whose .webp does not ship it simply
|
|
// saved a 404.
|
|
//
|
|
// Two guards below were each learned the hard way and MUST survive any future move:
|
|
// 1. a THROWN fetch is a MISS, not an error (blocked/offline/refused reject rather than
|
|
// returning !ok). Letting it escape skips the rasterise fallback entirely — measured
|
|
// during the wall migration, when check-poster-print-fallback went PASS -> FAIL with 0
|
|
// .html requests.
|
|
// 2. a blank raster must fail LOUDLY. An SVG inside an <img> is an isolated document, so a
|
|
// missing stylesheet paints every fill black with no error at all.
|
|
//
|
|
// `assetsBase` is the seam: the wall and the map profile resolve their own base, and this
|
|
// stays ignorant of where a bundle serves its posters from.
|
|
async function printPoster(item) {
|
|
if (busy()) return;
|
|
const kind = 'Poster';
|
|
const base = opts.assetsBase ? opts.assetsBase() : null;
|
|
const id = item?.id ?? item;
|
|
set(item, kind, 'fetch', 'fetching the poster', 25);
|
|
try {
|
|
// An explicit posterUrl still wins — it is how a caller points at a poster that is not
|
|
// <base>/posters/<id>.webp at all.
|
|
const direct = opts.posterUrl ? opts.posterUrl(item) : null;
|
|
let res = null;
|
|
const webp = direct || (base ? `${base}/posters/${id}.webp` : null);
|
|
if (webp) { try { res = await fetch(webp); } catch { /* a miss, see guard 1 */ } }
|
|
if (res && res.ok) { await deliverPoster(await res.blob(), item, kind); return; }
|
|
if (!base) throw new Error('no poster available for this item');
|
|
// NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are
|
|
// not shipped by default; the HTML is the SOURCE and is smaller than the image it makes.
|
|
set(item, kind, 'render', 'generating the poster', 45);
|
|
const htmlRes = await fetch(`${base}/posters/${id}.html`);
|
|
if (!htmlRes.ok) throw new Error(`poster not found (${htmlRes.status})`);
|
|
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js');
|
|
const blob = await rasterPoster(await htmlRes.text(), { scale: 2 });
|
|
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank');
|
|
await deliverPoster(blob, item, kind);
|
|
} catch (e) { fail(item, kind, e); }
|
|
}
|
|
|
|
// A 3ft SQUARE on a PORTRAIT sheet must fit by its SHORTER axis: scaling to the full page
|
|
// WIDTH makes it taller than the printable height and it overflows onto a second page.
|
|
// Constrain both axes and let object-fit letterbox it.
|
|
const posterObjectUrls = [];
|
|
async function deliverPoster(blob, item, kind) {
|
|
set(item, kind, 'save', 'opening the print dialog', 80);
|
|
const url = URL.createObjectURL(blob);
|
|
posterObjectUrls.push(url);
|
|
const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`;
|
|
openPrintFrame(
|
|
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title>'
|
|
+ '<style>@page{size:letter portrait;margin:12mm}'
|
|
+ 'html,body{margin:0;height:100%;background:#fff}'
|
|
+ 'body{display:flex;align-items:center;justify-content:center}'
|
|
+ 'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}'
|
|
+ '@media print{body{height:auto}'
|
|
+ 'img{width:auto;max-width:100%;max-height:100%;object-fit:contain}}</style></head>'
|
|
+ `<body><img src="${url}" alt="${esc(label(item))}"></body></html>`,
|
|
{ autoprint: true },
|
|
);
|
|
done(item, kind);
|
|
}
|
|
|
|
// ── 2. THE LIST ───────────────────────────────────────────────────────────
|
|
// The agent's FIRST option — "that's what the whole app was about": the list
|
|
// itself as a print-paginated table with a prayer checkbox per row.
|
|
async function printList(item, { columns, title } = {}) {
|
|
if (busy()) return;
|
|
const kind = 'List';
|
|
set(item, kind, 'fetch', 'fetching records', 30);
|
|
try {
|
|
const data = await rows(item);
|
|
const cols = columns || [
|
|
{ key: 'name', head: 'Name', cls: 'c-name' },
|
|
{ key: 'country_code_label', head: 'Country' },
|
|
{ key: 'population', head: 'People', cls: 'c-num', num: true },
|
|
{ key: 'religion_label', head: 'Religion' },
|
|
];
|
|
const head = cols.map((c) => `<th>${esc(c.head)}</th>`).join('') + '<th class="c-pray">✓</th>';
|
|
const body = data.map((r) => '<tr>' + cols.map((c) => {
|
|
const raw = r[c.key] ?? r[`${c.key}_label`] ?? '—';
|
|
const v = c.num && Number.isFinite(Number(raw))
|
|
? Number(raw).toLocaleString(lang()) : raw;
|
|
return `<td${c.cls ? ` class="${c.cls}"` : ''}>${esc(v)}</td>`;
|
|
}).join('') + '<td class="c-pray">□</td></tr>').join('');
|
|
|
|
set(item, kind, 'render', `laying out ${data.length} rows`, 70);
|
|
const docTitle = title || `${label(item)} — ${data.length}`;
|
|
openPrintFrame(
|
|
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(docTitle)}</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}'
|
|
+ '</style></head><body>'
|
|
+ `<div class="hd"><h1>${esc(docTitle)}</h1>`
|
|
+ `<p>${data.length} entries · pray for them by name</p></div>`
|
|
+ `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
|
|
+ '</body></html>',
|
|
{ autoprint: true },
|
|
);
|
|
done(item, kind);
|
|
} catch (e) { fail(item, kind, e); }
|
|
}
|
|
|
|
// ── 3. THE CARD DECK ──────────────────────────────────────────────────────
|
|
// One card per PAGE at the chosen stock, so the print dialog shows CARDS rather
|
|
// than letter paper. The geometry lives in the shared card renderer.
|
|
async function printCardDeck(item) {
|
|
if (busy()) return;
|
|
const kind = 'Card Deck';
|
|
set(item, kind, 'fetch', 'fetching records', 30);
|
|
try {
|
|
const data = await rows(item);
|
|
const [{ createCardRenderer }, strings] = await Promise.all([
|
|
import('../packet/card-render.js'),
|
|
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
|
]);
|
|
set(item, kind, 'render', `laying out ${data.length} cards`, 70);
|
|
const R = createCardRenderer({ strings, lang: lang(), stock: stock() });
|
|
openPrintFrame(R.buildDeck(item, data), { autoprint: true });
|
|
done(item, kind);
|
|
} catch (e) { fail(item, kind, e); }
|
|
}
|
|
|
|
// ── 4. THE PRAYER PACKET ──────────────────────────────────────────────────
|
|
// The full booklet: map one-pager, people groups 20-per-page with photos, the 12
|
|
// prayer points, the prayer wheel. Shares the renderer with the build-time
|
|
// generator (library/packet/packet-render.js), so a printed packet and a
|
|
// generated one cannot diverge.
|
|
//
|
|
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
|
|
// only the bundle knows where its posters are served from. If it cannot be
|
|
// fetched the packet still prints — a cover without the map beats no packet.
|
|
async function printPacket(item, { mapSvg = '' } = {}) {
|
|
if (busy()) return;
|
|
const kind = 'Prayer Packet';
|
|
set(item, kind, 'fetch', 'fetching people groups', 30);
|
|
try {
|
|
const data = await rows(item);
|
|
const [{ createPacketRenderer }, strings] = await Promise.all([
|
|
import('../packet/packet-render.js'),
|
|
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
|
]);
|
|
const R = createPacketRenderer({ strings, lang: lang() });
|
|
// Photos stay as URLs. The Node generator base64-inlines them so its file is
|
|
// self-contained offline; in a browser the print frame fetches them, and
|
|
// inlining ~95 images client-side would stall the tab for no gain.
|
|
const photos = new Map(
|
|
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
|
|
);
|
|
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
|
|
openPrintFrame(R.buildPacket(item, data, photos, mapSvg), { autoprint: true });
|
|
done(item, kind);
|
|
} catch (e) { fail(item, kind, e); }
|
|
}
|
|
|
|
return { exportState, dismiss, printPoster, printList, printCardDeck, printPacket };
|
|
}
|