Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

src/about-page.ts

228 lines · 11.7 KB · TypeScript
Raw
import { DEFAULT_PART_ID, PARTS } from './parts.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/pocket-piano'

export const ABOUT_CSS = String.raw`
:root { color-scheme: dark; --bg: #14161c; --surface: #1d2029; --line: #3a3f4e; --text: #eef0f5; --muted: #9aa1b2; --accent: #f2b544; --bad: #e5484d; }
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 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; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; }
.btn, button { font: inherit; color: var(--text); background: #2a2e3a; 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: 16px; }
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: #2a2e3a; 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); }
.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; }
h2 { margin: 0; font-size: 1.1rem; }
.howto { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 8px; }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.legend li { display: flex; align-items: center; gap: 10px; }
.swatch { flex: 0 0 auto; width: 28px; height: 18px; border-radius: 4px; background: #f7f6f2; position: relative; overflow: hidden; }
.swatch.part::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 5px; background: #5cc8a8; }
.swatch.middle-c { background: linear-gradient(to bottom, #f6e2b3, #fff3d6 30%); box-shadow: inset 0 -4px 0 #f2b544; }
.parts { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 8px; }
.parts legend { padding: 0; margin-bottom: 8px; color: var(--muted); font-size: 0.9rem; }
.part-option { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; background: #2a2e3a; cursor: pointer; touch-action: manipulation; }
.part-option:has(input:checked) { border-color: #5cc8a8; background: color-mix(in srgb, #5cc8a8 14%, #2a2e3a); }
.part-option input { accent-color: #5cc8a8; width: 20px; height: 20px; margin: 0; }
.part-option span { display: flex; flex-direction: column; line-height: 1.2; }
.part-option small { color: var(--muted); font-size: 0.85rem; }
`

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: #1d2029;
	color: #eef0f5;
	border: 1px solid #3a3f4e;
	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: #9aa1b2; }
.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 #3a3f4e; background: #2a2e3a; color: #eef0f5; }
.update-toast button.accent { background: #f2b544; border-color: #f2b544; color: #1a1a1a; font-weight: 700; }
.update-toast button[aria-busy='true'] { opacity: 0.72; }
`

export const ABOUT_JS = String.raw`
(function () {
	'use strict'
	var parts = window.pocketPianoParts
	var current = parts.read()
	var partName = document.querySelector('[data-part-name]')
	var partRange = document.querySelector('[data-part-range]')
	function showPart(part) {
		document.querySelectorAll('input[name="part"]').forEach(function (input) { input.checked = input.value === part.id })
		partName.textContent = part.label
		partRange.textContent = part.lowName + ' to ' + part.highName
	}
	showPart(current)
	document.querySelectorAll('input[name="part"]').forEach(function (input) {
		input.addEventListener('change', function () {
			if (input.checked) showPart(parts.write(input.value))
		})
	})
	window.addEventListener('pageshow', function () { showPart(parts.read()) })

	var api = window.pocketPianoUpdate
	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()

	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()
			})
		}
		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('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
}

/** Render the About page body (inside the shared document head built by app.ts). */
export function aboutBodyHtml(base: 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)
	return `<div class="wrap">
	<header class="header">
		<div class="brand">
			<img class="logo" src="${base}/icons/icon-192.png" width="28" height="28" alt="" decoding="async" title="@kentcdodds/pocket-piano" />
			<h1 class="title">About</h1>
		</div>
		<nav class="nav" aria-label="App">
			<a class="btn" href="${base}/">Piano</a>
			<a class="btn" href="${base}/about" aria-current="page">About</a>
		</nav>
	</header>
	<section class="card" aria-labelledby="part-heading">
		<h2 id="part-heading">Singing part</h2>
		<fieldset class="parts">
			<legend>Highlights your range and opens the piano there. Saved on this device.</legend>
${PARTS.map(
	(part) => `			<label class="part-option"><input type="radio" name="part" value="${part.id}"${part.id === DEFAULT_PART_ID ? ' checked' : ''} /><span>${part.label}<small>${part.lowName} to ${part.highName}</small></span></label>`,
).join('\n')}
		</fieldset>
	</section>
	<section class="card" aria-labelledby="howto-heading">
		<h2 id="howto-heading">How it works</h2>
		<ul class="legend">
			<li><span class="swatch part" aria-hidden="true"></span><span>Green strip: your part's range (<span data-part-name>Tenor</span>, <span data-part-range>C3 to A4</span>)</span></li>
			<li><span class="swatch middle-c" aria-hidden="true"></span><span>Gold key: middle C (C4)</span></li>
		</ul>
		<ul class="howto">
			<li>Tap a key to play it. Slide one finger to glide between keys. Press several keys for a chord.</li>
			<li>Drag two fingers sideways to move along the keyboard (all 88 keys). When you let go it settles on whole keys.</li>
			<li>In portrait the two rows are one keyboard: the top row picks up where the bottom row ends.</li>
			<li>Tap the range button at the top left to jump back to your part.</li>
			<li>Sustain lets notes ring after you lift your finger. Tap the note readout to hear the last note again.</li>
			<li>Use your phone's volume buttons for loudness. There is no volume setting in the app.</li>
			<li>Install it as an app: tap Install when it shows up, or use your browser menu and choose Install app or Add to Home screen. After the first visit it works offline.</li>
		</ul>
	</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 &#8599;</a></div>
	</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>`
}