Fix
This commit is contained in:
1 parent
844f86cda0
commit
77bf8d9f2e
109 files changed
+934
-3325
No files matched your search
+66
-127
@@ -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 () {});
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user