import { 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) => `
Checking...
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.
All ${PAINTINGS.length} images are courtesy of The Church of Jesus Christ of Latter-day Saints, from its Gospel Media Library. Each title below links to the image's page there.
The Church's Terms of Use let individuals view, download, and post Gospel Media material for their own personal, noncommercial use, including use in a Church calling. See the Media Library FAQ 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.
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.
All ${COLORING_PAGES.length} coloring pages are courtesy of The Church of Jesus Christ of Latter-day Saints, from the Coloring Pages collection in its Media Library. Each title links to the page there.
${shortSha}