Files
bible/assets/app.js
T
2026-09-04 08:08:32 -04:00

174 lines
6.6 KiB
JavaScript

// Page startup and verse modal wiring.
//
// shell.html is static and ships with an empty #content, so the first thing
// every load does is fetch the fragment its URL names and swap it in. That is
// the same request a boosted link makes, so there is one path into #content
// and nothing special about a first visit.
//
// The rest is the modal: the markup lives in shell.html and the bookmark lives
// in the service worker, so all this file does is decide which verse was
// clicked, fill the form, and show it -- Bootstrap handles opening, dismissing
// and focus, and htmx carries the save.
(function () {
'use strict';
var currentUrl = '';
function modalEl() {
return document.getElementById('verse-dialog');
}
function status(text) {
var el = document.getElementById('verse-dialog-status');
if (el) el.textContent = text || '';
return el;
}
// navigator.clipboard only exists in a secure context, so over plain http
// this always falls through to a selectable field rather than failing.
function copyLink(url) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(function () {
status('Link copied.');
}, function () { offerManualCopy(url); });
} else {
offerManualCopy(url);
}
}
function offerManualCopy(url) {
var el = status('Copy this link:');
if (!el) return;
var field = document.createElement('input');
field.type = 'text';
field.readOnly = true;
field.value = url;
field.className = 'verse-dialog-url form-control form-control-sm mt-2';
el.appendChild(field);
field.focus();
field.select();
}
// -- verse numbers ------------------------------------------------------
function verseInfo(span) {
var article = document.querySelector('article.book');
var section = span.closest('.chapter');
var book = article
? (article.getAttribute('data-name') || article.getAttribute('data-book'))
: '';
var chapter = section ? section.getAttribute('data-chapter') : '';
var page = location.pathname.split('/').pop() || 'index.html';
return {
ref: book + ' ' + chapter + ':' + (span.textContent || '').trim(),
// Relative, so a saved bookmark survives a change of host or port.
href: page + '#' + span.id,
url: location.origin + location.pathname + '#' + span.id
};
}
function openFor(span) {
var el = modalEl();
if (!el || !window.bootstrap) return;
var info = verseInfo(span);
currentUrl = info.url;
document.getElementById('verse-dialog-ref').textContent = info.ref;
el.querySelector('input[name="ref"]').value = info.ref;
el.querySelector('input[name="href"]').value = info.href;
status('');
window.bootstrap.Modal.getOrCreateInstance(el).show();
}
function hideModal() {
var el = modalEl();
if (!el || !window.bootstrap) return;
var instance = window.bootstrap.Modal.getInstance(el);
if (instance) instance.hide();
}
// Applied here rather than baked into the HTML: without this script the
// numbers are plain text, and announcing them as buttons would lie. It also
// earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx
// swap, because the new verses arrive bare.
function markVerses(root) {
var verses = (root || document).querySelectorAll('.chapter .v');
for (var i = 0; i < verses.length; i++) {
verses[i].setAttribute('role', 'button');
verses[i].setAttribute('tabindex', '0');
verses[i].setAttribute('aria-label',
'Verse ' + (verses[i].textContent || '').trim() + ', options');
}
}
// Delegated at the document, so it survives swaps and is bound only once.
// Dismissal is not handled here: the close button carries data-bs-dismiss,
// and Bootstrap owns the backdrop and Escape.
document.addEventListener('click', function (event) {
if (!event.target.closest) return;
var verse = event.target.closest('.v');
if (verse && verse.id) { event.preventDefault(); openFor(verse); return; }
if (event.target.closest('[data-action="copy"]')) copyLink(currentUrl);
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
var el = document.activeElement;
if (el && el.classList && el.classList.contains('v') && el.id) {
event.preventDefault();
openFor(el);
}
});
// Close the modal once a bookmark write has come back and swapped, leaving
// the status line up long enough to be read.
document.body.addEventListener('htmx:afterSwap', function (event) {
markVerses(event.target);
if (event.detail.target && event.detail.target.id === 'bookmark') {
setTimeout(hideModal, 900);
}
});
// -- startup ------------------------------------------------------------
function fragmentUrl() {
return (location.pathname.split('/').pop() || 'index.html') + location.search;
}
// The content arrives after the document has been parsed, so the browser has
// long given up on the fragment identifier by the time the verse exists.
// Doing it here is what makes a link to GEN.html#v1_1 land on the verse.
function scrollToHash() {
if (!location.hash) return;
var el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (el) el.scrollIntoView();
}
// api/bookmark has no origin server -- it is answered by the worker -- so
// there is nothing to ask until the worker is controlling this page.
// controller, not ready: ready can resolve a tick before clients.claim()
// takes effect, and a request sent in that gap reaches the network and 404s.
function whenControlled(fn) {
if (!('serviceWorker' in navigator)) return;
if (navigator.serviceWorker.controller) return fn();
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
}
// Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
htmx.ajax('GET', fragmentUrl(), { target: '#content', swap: 'innerHTML' })
.then(scrollToHash);
// Registered from the page it is going to serve, not from a page of its
// own: there is nothing to wait for and nothing to reload. This load is
// already the real app -- the worker installs alongside it, and takes over
// for the bookmark, the offline download and every request from then on.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
whenControlled(function () {
document.body.dispatchEvent(new CustomEvent('worker-ready'));
});
});
})();