← Public packages
@kentcdodds/christ-paintings
Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church, plus coloring pages of the Savior. Swipe to move. Works offline.
src/about-page.ts
274 lines · 14.4 KB · TypeScriptimport { COLORING_PAGES } from './coloring.ts'
import { allImagePaths, totalImageBytes } from './images.ts'
import { COLORING_COLLECTION_URL, MEDIA_FAQ_URL, MEDIA_LIBRARY_URL, PAINTINGS, TERMS_URL } from './paintings.ts'
import type { AppVersion } from './version.ts'
// About page, adapted from @kentcdodds/package-app-kit starter-remix:
// app/controllers/about.tsx (route + data), app/ui/layout.tsx (header, brand, nav),
// app/ui/about-panel.tsx (SHA, message, published, committed, last checked, code link,
// "Check for updates"), and app/ui/local-time.ts (times in the device's timezone).
export const CODE_URL = 'https://kody.codes/@kentcdodds/christ-paintings'
export const ABOUT_CSS = String.raw`
:root { color-scheme: dark; --bg: #0f1014; --surface: #17191f; --line: #2c2f38; --text: #eceae4; --muted: #a29f97; --accent: #d6b678; --bad: #e5484d; }
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-tap-highlight-color: transparent; }
body { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
.wrap { max-width: 40rem; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; flex-direction: column; gap: 16px; }
.brand { display: flex; align-items: center; gap: 12px; min-height: 44px; }
.logo { width: 28px; height: 28px; border-radius: 7px; }
.title { margin: 0; font-size: 1.35rem; line-height: 1.2; flex: 1 1 auto; font-weight: 600; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; }
.btn, button { font: inherit; color: var(--text); background: #22252d; border: 1px solid var(--line); border-radius: 10px; min-height: 44px; padding: 0 14px; display: inline-flex; align-items: center; text-decoration: none; cursor: pointer; touch-action: manipulation; }
.btn[aria-current='page'] { border-color: var(--accent); color: var(--accent); }
.btn.kody { background: var(--accent); border-color: var(--accent); color: #1a1a1a; font-weight: 700; }
button[aria-busy='true'] { opacity: 0.72; cursor: progress; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 14px; }
h2 { margin: 0; font-size: 1.1rem; font-weight: 600; }
p { margin: 0; }
dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
dt { color: var(--muted); font-size: 0.85rem; }
dd { margin: 0; overflow-wrap: anywhere; }
code { font: 0.95em ui-monospace, SFMono-Regular, Menlo, monospace; background: #22252d; padding: 1px 6px; border-radius: 6px; }
a { color: var(--accent); }
.cluster { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.muted { color: var(--muted); }
.small { font-size: 0.9rem; }
.error { border: 1px solid color-mix(in srgb, var(--bad) 45%, var(--line)); background: color-mix(in srgb, var(--bad) 10%, var(--surface)); border-radius: 10px; padding: 10px 12px; }
.error p { margin: 4px 0 0; }
[hidden] { display: none !important; }
.howto { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 8px; }
.credits { margin: 0; padding-left: 1.6rem; display: flex; flex-direction: column; gap: 8px; }
.credits li { padding-left: 2px; }
.credits .artist { color: var(--muted); }
.credits .note { color: var(--muted); font-size: 0.85rem; }
.offline { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.offline progress { width: 100%; height: 8px; accent-color: var(--accent); }
.offline p { margin: 0; }
.offline button { align-self: flex-start; }
.offline button[hidden] { display: none; }
`
export const ABOUT_JS = String.raw`
(function () {
'use strict'
var api = window.christPaintingsUpdate
document.querySelectorAll('[data-local-time]').forEach(function (el) {
var iso = el.getAttribute('data-local-time')
if (iso) { el.textContent = api.formatLocal(iso); el.title = iso }
})
var lastEl = document.querySelector('[data-last-checked]')
function showLast() {
var iso = api.readLastCheck()
lastEl.textContent = iso ? api.formatLocal(iso) : 'Never'
if (iso) lastEl.title = iso
}
showLast()
// Offline status: how many images the service worker has saved, with a progress bar
// while it downloads the rest.
var offlineEl = document.querySelector('[data-offline]')
var barEl = document.querySelector('[data-offline-bar]')
var saveBtn = document.querySelector('[data-offline-save]')
var paths = JSON.parse(offlineEl.getAttribute('data-paths') || '[]')
var total = paths.length
function render(done, running, failed) {
barEl.max = total
barEl.value = done
var text = done + ' of ' + total + ' images saved'
if (done >= total) text = 'All ' + total + ' images are saved on this device'
else if (running) text = 'Saving for offline: ' + done + ' of ' + total
else if (failed) text = done + ' of ' + total + ' saved. Connect to finish.'
offlineEl.textContent = text
saveBtn.hidden = done >= total
saveBtn.disabled = Boolean(running)
}
function showOffline() {
if (!('caches' in window)) { offlineEl.textContent = 'Not available in this browser'; saveBtn.hidden = true; return }
caches.open('christ-paintings-images').then(function (cache) {
return cache.keys()
}).then(function (keys) {
var have = {}
keys.forEach(function (req) { have[new URL(req.url).pathname] = true })
render(paths.filter(function (p) { return have[p] }).length, false, false)
}).catch(function () { offlineEl.textContent = 'Unknown' })
}
function startWarm() {
if (!('serviceWorker' in navigator)) return
navigator.serviceWorker.ready.then(function (reg) {
var worker = reg.active || navigator.serviceWorker.controller
if (worker) worker.postMessage({ type: 'WARM_ALL' })
}).catch(function () {})
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (e) {
if (e.data && e.data.type === 'WARM_PROGRESS') render(e.data.done, e.data.running, e.data.failed)
})
}
saveBtn.addEventListener('click', function () { saveBtn.disabled = true; startWarm() })
showOffline()
startWarm()
var btn = document.querySelector('[data-about-check-update]')
var status = document.querySelector('[data-about-status]')
var errorBox = document.querySelector('[data-about-error]')
var checking = false
btn.addEventListener('click', function () {
if (checking) return
checking = true
status.textContent = ''
errorBox.hidden = true
btn.disabled = true
btn.setAttribute('aria-busy', 'true')
// Spin-delay: only say "Checking..." if it takes a moment, then keep it briefly.
var shownAt = null
var timer = setTimeout(function () { btn.textContent = 'Checking...'; shownAt = Date.now() }, 200)
function finish() {
clearTimeout(timer)
var wait = shownAt ? Math.max(0, 400 - (Date.now() - shownAt)) : 0
return new Promise(function (r) { setTimeout(r, wait) }).then(function () {
checking = false
btn.disabled = false
btn.removeAttribute('aria-busy')
btn.textContent = 'Check for updates'
showLast()
showOffline()
})
}
api.check().then(function (result) {
return finish().then(function () {
if (result.kind === 'worker') status.textContent = 'Service worker waiting'
else if (result.kind === 'published') status.textContent = 'Latest ' + result.token
else status.textContent = 'Up to date'
api.showResult(result)
})
}, function (error) {
return finish().then(function () {
errorBox.querySelector('p').textContent = error && error.message ? error.message : String(error)
errorBox.hidden = false
})
})
})
})()
`
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
/** Render the About page body (inside the shared document head built by app.ts). */
export function aboutBodyHtml(base: string, assetBase: string, version: AppVersion) {
const sha = escapeHtml(version.sha)
const shortSha = escapeHtml(version.shortSha || version.sha || '-')
const message = escapeHtml(version.message || '-')
const published = escapeHtml(version.publishedAt)
const committed = escapeHtml(version.committedAt)
const imagePaths = escapeHtml(JSON.stringify(allImagePaths().map((path) => `${base}${path}`)))
const totalMb = (totalImageBytes() / 1_000_000).toFixed(1)
const coloringCredits = COLORING_PAGES.map(
(c) => ` <li><a href="${escapeHtml(c.sourceUrl)}">${escapeHtml(c.title)}</a></li>`,
).join('\n')
const credits = PAINTINGS.map((p) => {
const note = p.artistSource
? ` <span class="note">(artist from the <a href="${escapeHtml(p.artistSource)}">Gospel Art Book</a>)</span>`
: ''
return ` <li><a href="${escapeHtml(p.sourceUrl)}">${escapeHtml(p.title)}</a> <span class="artist">by ${escapeHtml(p.artist)}</span>${note}</li>`
}).join('\n')
return `<div class="wrap">
<header class="header">
<div class="brand">
<img class="logo" src="${assetBase}/icons/icon-192.png" width="28" height="28" alt="" decoding="async" title="@kentcdodds/christ-paintings" />
<h1 class="title">About</h1>
</div>
<nav class="nav" aria-label="App">
<a class="btn" href="${base}/">Paintings</a>
<a class="btn" href="${base}/coloring">Coloring</a>
<a class="btn" href="${base}/about" aria-current="page">About</a>
</nav>
</header>
<section class="card" aria-labelledby="howto-heading">
<h2 id="howto-heading">How to use</h2>
<ul class="howto">
<li>Swipe left for the next painting and right for the one before. You can also tap the left or right edge of the screen, or use the arrow keys on a computer.</li>
<li>Tap the middle of the painting to hide or show the title. Tap the small circle by the title to open this page.</li>
<li>The app remembers the last painting you looked at, and keeps the screen on while it is open when your phone allows it.</li>
<li>Tap the palette by the title for coloring pages. Swipe to pick a page, then tap Color this page. Tap a space to fill it with color, or pick a brush size and draw. Undo, the eraser, and the trash can (it asks first) fix mistakes. The share button saves the finished picture or sends it to your photos. Each page is saved on this device as you color.</li>
<li>Install it as an app from your browser menu (Install app or Add to Home screen). After the first visit every painting and coloring page is saved on your phone (about ${totalMb} MB), so it works without Wi-Fi.</li>
</ul>
<div class="offline">
<progress data-offline-bar max="1" value="0"></progress>
<p class="muted small" role="status"><span data-offline data-paths="${imagePaths}">Checking...</span></p>
<button type="button" data-offline-save hidden>Save everything for offline</button>
</div>
</section>
<section class="card" aria-labelledby="kody-heading">
<h2 id="kody-heading">Source and fork on Kody</h2>
<p>This app is built and hosted with Kody. Its code is public and MIT licensed, so you can read it or make your own copy.</p>
<div class="cluster"><a class="btn kody" href="${CODE_URL}" target="_blank" rel="noopener" data-kody-link>View this app on Kody ↗</a></div>
</section>
<section class="card" aria-labelledby="credits-heading">
<h2 id="credits-heading">The paintings</h2>
<p>All ${PAINTINGS.length} images are courtesy of The Church of Jesus Christ of Latter-day Saints, from its <a href="${MEDIA_LIBRARY_URL}">Gospel Media Library</a>. Each title below links to the image's page there.</p>
<p class="muted small">The Church's <a href="${TERMS_URL}">Terms of Use</a> let individuals view, download, and post Gospel Media material for their own personal, noncommercial use, including use in a Church calling. See the <a href="${MEDIA_FAQ_URL}">Media Library FAQ</a> for more. The app loads each image from the Church's website and keeps a copy on your device so it works without Wi-Fi. Images marked Church Use Only, Do Not Copy, or with other restrictions were left out.</p>
<p class="muted small">This app is free and noncommercial. Its code is MIT licensed, but the paintings and coloring pages are not: they belong to The Church of Jesus Christ of Latter-day Saints (or the rights holders named on each image's Church page) and stay under the Church's terms. Anyone who copies this app needs to follow those terms too.</p>
<ol class="credits">
${credits}
</ol>
</section>
<section class="card" aria-labelledby="coloring-heading">
<h2 id="coloring-heading">The coloring pages</h2>
<p>All ${COLORING_PAGES.length} coloring pages are courtesy of The Church of Jesus Christ of Latter-day Saints, from the <a href="${COLORING_COLLECTION_URL}">Coloring Pages</a> collection in its Media Library. Each title links to the page there.</p>
<ol class="credits">
${coloringCredits}
</ol>
</section>
<section class="card" data-about-panel>
<dl>
<div><dt>SHA</dt><dd><code title="${sha}">${shortSha}</code></dd></div>
<div><dt>Message</dt><dd>${message}</dd></div>
<div><dt>Published</dt><dd data-local-time="${published}">${published || '-'}</dd></div>
<div><dt>Committed</dt><dd data-local-time="${committed}">${committed || '-'}</dd></div>
<div><dt>Last checked</dt><dd data-last-checked>Never</dd></div>
</dl>
<div class="cluster">
<button type="button" data-about-check-update>Check for updates</button>
<span class="muted" data-about-status role="status"></span>
</div>
<div class="error" data-about-error role="alert" hidden><strong>Update check failed</strong><p></p></div>
</section>
</div>`
}
export const UPDATE_TOAST_CSS = String.raw`
.update-toast {
position: fixed;
z-index: 80;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
width: min(420px, calc(100% - 24px));
display: grid;
grid-template-columns: 1fr auto;
gap: 8px 12px;
align-items: center;
padding: 12px;
background: #17191f;
color: #eceae4;
border: 1px solid #2c2f38;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
font: 14px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
touch-action: manipulation;
}
.update-toast[hidden] { display: none; }
.update-toast p { margin: 2px 0 0; color: #a29f97; }
.update-toast .update-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.update-toast button { font: inherit; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid #2c2f38; background: #22252d; color: #eceae4; }
.update-toast button.accent { background: #d6b678; border-color: #d6b678; color: #1a1a1a; font-weight: 700; }
.update-toast button[aria-busy='true'] { opacity: 0.72; }
`