This commit is contained in:
mark committed 2026-09-03 17:24:22 -04:00
1 parent 844f86cda0
commit 77bf8d9f2e
109 files changed
+934 -3325

No files matched your search

+66 -127
View File
@@ -1,107 +1,28 @@
// Bookmark UI. Deliberately small and dependency-free: the state is one
// verse reference, so it lives in localStorage rather than IndexedDB --
// localStorage reads synchronously, which lets the index page paint the
// bookmark link on first paint instead of flashing an empty slot.
// Verse modal wiring. The modal markup lives in base.njk 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 KEY = 'bible:bookmark';
var dialog = null;
var current = null;
var currentUrl = '';
// localStorage throws (not returns null) in some private-browsing modes,
// so every access is guarded and the feature degrades to a no-op.
function readBookmark() {
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); }
catch (e) { return null; }
}
function writeBookmark(value) {
try { localStorage.setItem(KEY, JSON.stringify(value)); return true; }
catch (e) { return false; }
}
// -- index page ---------------------------------------------------------
function renderBookmarkLink() {
var slot = document.getElementById('bookmark');
if (!slot) return;
var mark = readBookmark();
slot.textContent = '';
if (!mark || !mark.href) { slot.hidden = true; return; }
var label = document.createElement('span');
label.className = 'bookmark-label';
label.textContent = 'Bookmark';
var link = document.createElement('a');
link.className = 'bookmark-link';
link.href = mark.href;
link.textContent = mark.ref;
slot.appendChild(label);
slot.appendChild(document.createTextNode(' '));
slot.appendChild(link);
slot.hidden = false;
}
// -- dialog -------------------------------------------------------------
function buildDialog() {
var d = document.createElement('dialog');
d.id = 'verse-dialog';
d.className = 'verse-dialog';
d.innerHTML =
'<h2 class="verse-dialog-ref"></h2>' +
'<p class="verse-dialog-note" hidden>Currently bookmarked.</p>' +
'<div class="verse-dialog-actions">' +
'<button type="button" data-action="bookmark">Set bookmark</button>' +
'<button type="button" data-action="copy">Copy link</button>' +
'<button type="button" data-action="close">Close</button>' +
'</div>' +
'<p class="verse-dialog-status" role="status" aria-live="polite"></p>';
document.body.appendChild(d);
d.addEventListener('click', onDialogClick);
return d;
function modalEl() {
return document.getElementById('verse-dialog');
}
function status(text) {
var el = dialog.querySelector('.verse-dialog-status');
el.textContent = text || '';
var el = document.getElementById('verse-dialog-status');
if (el) el.textContent = text || '';
return el;
}
function closeSoon() {
setTimeout(function () { if (dialog.open) dialog.close(); }, 900);
}
function onDialogClick(event) {
// A click landing on the dialog itself is the backdrop, not the content.
if (event.target === dialog) { dialog.close(); return; }
var button = event.target.closest('button[data-action]');
if (!button) return;
var action = button.getAttribute('data-action');
if (action === 'close') {
dialog.close();
} else if (action === 'bookmark') {
var saved = writeBookmark({
ref: current.ref, href: current.href, savedAt: Date.now()
});
status(saved ? 'Bookmark set.' : 'Could not save: storage unavailable.');
renderBookmarkLink();
dialog.querySelector('.verse-dialog-note').hidden = !saved;
if (saved) closeSoon();
} else if (action === 'copy') {
copyLink(current.url);
}
}
// 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.');
closeSoon();
}, function () { offerManualCopy(url); });
} else {
offerManualCopy(url);
@@ -110,12 +31,12 @@
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';
el.appendChild(document.createTextNode(' '));
field.className = 'verse-dialog-url form-control form-control-sm mt-2';
el.appendChild(field);
field.focus();
field.select();
@@ -140,55 +61,73 @@
}
function openFor(span) {
if (!dialog) dialog = buildDialog();
current = verseInfo(span);
dialog.querySelector('.verse-dialog-ref').textContent = current.ref;
var mark = readBookmark();
dialog.querySelector('.verse-dialog-note').hidden =
!(mark && mark.href === current.href);
var el = modalEl();
if (!el || !window.bootstrap) return;
var info = verseInfo(span);
currentUrl = info.url;
el.querySelector('.verse-dialog-ref').textContent = info.ref;
el.querySelector('input[name="ref"]').value = info.ref;
el.querySelector('input[name="href"]').value = info.href;
status('');
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
window.bootstrap.Modal.getOrCreateInstance(el).show();
}
function initVerses() {
var verses = document.querySelectorAll('.chapter .v');
if (!verses.length) return;
// Applied here rather than baked into the HTML: without this script the
// numbers are plain text, and announcing them as buttons would lie.
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');
}
document.addEventListener('click', function (event) {
var verse = event.target.closest ? event.target.closest('.v') : null;
if (verse && verse.id) { event.preventDefault(); openFor(verse); }
});
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);
}
});
}
function init() {
renderBookmarkLink();
initVerses();
}
// Another tab changing the bookmark updates this one's index link.
window.addEventListener('storage', function (event) {
if (event.key === KEY) renderBookmarkLink();
// 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);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
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);
}
});
markVerses(document);
// The shell already carries the bookmark, so this is only for the case where
// the worker started controlling the page after it was rendered.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(function () {
document.body.dispatchEvent(new CustomEvent('bookmark-load'));
}).catch(function () {});
}
})();