This commit is contained in:
mark committed 2026-09-02 18:24:01 -04:00
commit 844f86cda0
99 files changed
+53591

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
// 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.
(function () {
'use strict';
var KEY = 'bible:bookmark';
var dialog = null;
var current = null;
// 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 status(text) {
var el = dialog.querySelector('.verse-dialog-status');
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);
}
}
function offerManualCopy(url) {
var el = status('Copy this link:');
var field = document.createElement('input');
field.type = 'text';
field.readOnly = true;
field.value = url;
field.className = 'verse-dialog-url';
el.appendChild(document.createTextNode(' '));
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) {
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);
status('');
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
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.
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();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();