Skip to content
← 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.

FollowForkOpen app

src/client-script.ts

251 lines · 8.6 KB · TypeScript
Raw
// Viewer script, inlined on the main page. Plain JS in a raw string: no backticks or
// template placeholders inside.
export const CLIENT_JS = String.raw`
(function () {
	'use strict'
	var KEY_CURRENT = 'christ-paintings:current'
	var KEY_CAPTION = 'christ-paintings:caption'
	var GAP = 24
	var DURATION = 340
	var EASE = 'cubic-bezier(0.22, 0.61, 0.36, 1)'

	var root = document.documentElement
	var appBase = root.getAttribute('data-app-base') || ''
	var paintings = JSON.parse(document.getElementById('paintings-data').textContent || '[]')
	var count = paintings.length
	var viewer = document.getElementById('viewer')
	var caption = document.getElementById('caption')
	var capTitle = document.getElementById('cap-title')
	var capArtist = document.getElementById('cap-artist')
	var aboutLink = document.getElementById('about-link')
	var coloringLink = document.getElementById('coloring-link')
	var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'))

	var state = {
		index: 0,
		id: '',
		count: count,
		captionHidden: false,
		animating: false,
		dragging: false,
		moves: 0,
		wakeLock: 'unsupported',
		loaded: {},
	}
	window.christPaintings = { state: state, next: function () { go(1) }, prev: function () { go(-1) }, show: show }

	function wrap(i) { return ((i % count) + count) % count }
	function src(i) { return appBase + '/img/p/' + paintings[wrap(i)].id + '.jpg' }
	function readStore(key) { try { return localStorage.getItem(key) } catch (e) { return null } }
	function writeStore(key, value) { try { localStorage.setItem(key, value) } catch (e) {} }

	var saved = readStore(KEY_CURRENT)
	for (var s = 0; s < count; s++) if (paintings[s].id === saved) state.index = s
	state.captionHidden = readStore(KEY_CAPTION) === 'hidden'

	// slides[k] holds painting index + offset[k]; offsets are -1, 0, 1.
	var offsets = [-1, 0, 1]

	function width() { return viewer.clientWidth || window.innerWidth }
	function place(dx, animate) {
		var w = width()
		slides.forEach(function (el, k) {
			el.style.transition = animate ? 'transform ' + DURATION + 'ms ' + EASE : 'none'
			el.style.transform = 'translate3d(' + (offsets[k] * (w + GAP) + dx) + 'px,0,0)'
			el.setAttribute('aria-hidden', offsets[k] === 0 ? 'false' : 'true')
		})
	}
	function setImage(el, i) {
		var img = el.querySelector('img')
		var p = paintings[wrap(i)]
		var url = src(i)
		if (img.getAttribute('data-id') === p.id) return
		img.classList.remove('ready')
		img.setAttribute('data-id', p.id)
		img.alt = p.alt || p.title
		img.width = p.width
		img.height = p.height
		img.onload = function () {
			if (img.getAttribute('data-id') !== p.id) return
			state.loaded[p.id] = true
			img.classList.add('ready')
		}
		img.src = url
		if (img.complete && img.naturalWidth) img.onload()
	}
	var warm = {}
	function preload(i) {
		var url = src(i)
		if (warm[url]) return
		warm[url] = new Image()
		warm[url].decoding = 'async'
		warm[url].src = url
	}
	function renderCaption() {
		var p = paintings[state.index]
		capTitle.textContent = p.title
		capArtist.textContent = p.artist
		caption.classList.toggle('hidden', state.captionHidden)
		caption.setAttribute('aria-hidden', state.captionHidden ? 'true' : 'false')
		aboutLink.tabIndex = state.captionHidden ? -1 : 0
		if (coloringLink) coloringLink.tabIndex = state.captionHidden ? -1 : 0
	}
	function render() {
		slides.forEach(function (el, k) { setImage(el, state.index + offsets[k]) })
		preload(state.index + 2)
		preload(state.index - 2)
		state.id = paintings[state.index].id
		writeStore(KEY_CURRENT, state.id)
		renderCaption()
		viewer.setAttribute('aria-label', 'Painting ' + (state.index + 1) + ' of ' + count + ': ' + paintings[state.index].title)
		place(0, false)
	}

	function show(i) {
		state.index = wrap(i)
		render()
	}

	// Slide the track one step (dir 1 = next, -1 = previous), then recycle the slide
	// that went off screen so the next and previous paintings are always loaded.
	var settleTimer = null
	function go(dir, fromDx) {
		if (!count) return
		if (state.animating) finish()
		state.animating = true
		var w = width()
		place(fromDx || 0, false)
		void viewer.offsetWidth
		place(-dir * (w + GAP), true)
		pending = dir
		settleTimer = setTimeout(finish, DURATION + 40)
	}
	var pending = 0
	function finish() {
		clearTimeout(settleTimer)
		if (!pending) { state.animating = false; return }
		var dir = pending
		pending = 0
		state.index = wrap(state.index + dir)
		state.moves++
		offsets = offsets.map(function (o) {
			var n = o - dir
			if (n < -1) n = 1
			if (n > 1) n = -1
			return n
		})
		state.animating = false
		render()
	}
	function snapBack(fromDx) {
		place(fromDx, false)
		void viewer.offsetWidth
		place(0, true)
	}

	function toggleCaption() {
		state.captionHidden = !state.captionHidden
		writeStore(KEY_CAPTION, state.captionHidden ? 'hidden' : 'shown')
		renderCaption()
	}

	// ---------- Pointer gestures ----------
	var start = null
	var samples = []
	viewer.addEventListener('pointerdown', function (e) {
		if (e.target.closest && e.target.closest('a')) return
		if (start || (e.pointerType === 'mouse' && e.button !== 0)) return
		requestWakeLock()
		if (state.animating) finish()
		start = { id: e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp, dx: 0, horizontal: null }
		samples = [{ x: e.clientX, t: e.timeStamp }]
		try { viewer.setPointerCapture(e.pointerId) } catch (err) {}
	})
	viewer.addEventListener('pointermove', function (e) {
		if (!start || e.pointerId !== start.id) return
		var dx = e.clientX - start.x
		var dy = e.clientY - start.y
		if (start.horizontal === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
			start.horizontal = Math.abs(dx) >= Math.abs(dy)
		}
		samples.push({ x: e.clientX, t: e.timeStamp })
		while (samples.length > 2 && e.timeStamp - samples[0].t > 100) samples.shift()
		if (start.horizontal) {
			state.dragging = true
			viewer.classList.add('dragging')
			start.dx = dx
			place(dx, false)
		}
	})
	function end(e, cancelled) {
		if (!start || e.pointerId !== start.id) return
		var s = start
		start = null
		state.dragging = false
		viewer.classList.remove('dragging')
		var w = width()
		var dx = e.clientX - s.x
		var dy = e.clientY - s.y
		if (cancelled) { if (s.horizontal) snapBack(s.dx); return }
		if (s.horizontal) {
			var first = samples[0]
			var dt = Math.max(1, e.timeStamp - first.t)
			var v = (e.clientX - first.x) / dt
			var commit = Math.abs(dx) > w * 0.18 || (Math.abs(v) > 0.35 && Math.abs(dx) > 24)
			if (commit && dx !== 0) go(dx < 0 ? 1 : -1, dx)
			else snapBack(dx)
			return
		}
		var moved = Math.abs(dx) > 10 || Math.abs(dy) > 10
		if (moved || e.timeStamp - s.t > 600) return
		var rect = viewer.getBoundingClientRect()
		var x = (e.clientX - rect.left) / rect.width
		if (x < 0.22) go(-1)
		else if (x > 0.78) go(1)
		else toggleCaption()
	}
	viewer.addEventListener('pointerup', function (e) { end(e, false) })
	viewer.addEventListener('pointercancel', function (e) { end(e, true) })
	viewer.addEventListener('contextmenu', function (e) { e.preventDefault() })

	document.addEventListener('keydown', function (e) {
		if (e.altKey || e.ctrlKey || e.metaKey) return
		if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); go(1) }
		else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(-1) }
		else if (e.key === 'Home') { e.preventDefault(); show(0) }
		else if (e.key === 'End') { e.preventDefault(); show(count - 1) }
	})
	window.addEventListener('resize', function () { if (!start) place(0, false) })

	// ---------- Keep the screen on while viewing ----------
	var lock = null
	function requestWakeLock() {
		if (!('wakeLock' in navigator)) { state.wakeLock = 'unsupported'; return }
		if (lock || document.visibilityState !== 'visible') return
		navigator.wakeLock.request('screen').then(function (l) {
			lock = l
			state.wakeLock = 'active'
			l.addEventListener('release', function () { lock = null; state.wakeLock = 'released' })
		}, function () { state.wakeLock = 'denied' })
	}
	document.addEventListener('visibilitychange', function () {
		if (document.visibilityState === 'visible') requestWakeLock()
	})

	// ---------- Offline: ask the service worker to save every image ----------
	function warmCache() {
		if (!('serviceWorker' in navigator)) return
		navigator.serviceWorker.ready.then(function (reg) {
			var worker = reg.active || navigator.serviceWorker.controller
			if (!worker) return
			worker.postMessage({ type: 'WARM_ALL' })
		}).catch(function () {})
	}

	render()
	requestWakeLock()
	if (document.readyState === 'complete') setTimeout(warmCache, 1500)
	else window.addEventListener('load', function () { setTimeout(warmCache, 1500) })
	viewer.focus({ preventScroll: true })
})()
`