Fixes
This commit is contained in:
1 parent
9407c26d2d
commit
bfb3c87558
5 files changed
+204
-129
No files matched your search
+25
-102
@@ -1,99 +1,36 @@
|
|||||||
// Page startup and verse modal wiring.
|
// Page startup.
|
||||||
//
|
//
|
||||||
// Pages arrive whole -- composed by the service worker, or by boot.html on the
|
// Pages arrive whole -- composed by the service worker, or by boot.html on the
|
||||||
// visit before there is one -- so there is nothing here that builds a page.
|
// visit before there is one -- so there is nothing here that builds a page.
|
||||||
// What is left is the four things a composed document cannot do for itself:
|
// What is left is the four things a composed document cannot do for itself:
|
||||||
// mark up the verse numbers as controls, run the modal, put a swapped-in page
|
// mark up the verse numbers as controls, put a swapped-in page where it
|
||||||
// where it belongs before it is painted, and tell the chrome when a worker has
|
// belongs before it is painted, land the reference picker where the reader
|
||||||
// appeared to answer it.
|
// asked to go, and tell the chrome when a worker has appeared to answer it.
|
||||||
//
|
//
|
||||||
// The reference picker used to be here as well, built by reading the chapters
|
// Two things used to live here and no longer do, both for the same reason:
|
||||||
// and verses off the book on the page. It is markup and htmx now: sw.js
|
// they were reading the page to work out something the worker already knew.
|
||||||
// renders it from bible/verses.json and answers api/reference, and the scroll
|
|
||||||
// and the address-bar update that a change event used to run through here are
|
|
||||||
// response headers on the answer. See templates/reference.njk.
|
|
||||||
//
|
//
|
||||||
// The modal's markup is in templates/base.njk and the bookmark is in the
|
// The reference picker was built by reading the chapters and verses off the
|
||||||
// worker, so all this file does for it is decide which verse was clicked, fill
|
// book on screen. It is markup and htmx now: sw.js renders it from
|
||||||
// the form, and show it -- Bootstrap handles opening, dismissing and focus,
|
// bible/verses.json and answers api/reference, and the address-bar update that
|
||||||
// and htmx carries the save.
|
// a change event used to run through here is a response header on the answer.
|
||||||
|
// See templates/reference.njk.
|
||||||
|
//
|
||||||
|
// The verse dialog was built by scraping the book name out of article
|
||||||
|
// [data-name] and the chapter out of the enclosing section, then filling a
|
||||||
|
// form and showing it. sw.js answers api/verse with the whole of the dialog,
|
||||||
|
// htmx swaps it into the empty modal in templates/base.njk, and Bootstrap
|
||||||
|
// opens it -- which is htmx's own bootstrap-modal example, with the click
|
||||||
|
// delegated because the trigger is one of 38,000 generated verse numbers and
|
||||||
|
// cannot carry attributes of its own. See templates/verse.njk.
|
||||||
|
//
|
||||||
|
// What is left of the dialog here is markVerses, and only because the numbers
|
||||||
|
// have to look and behave like controls before anyone can click one.
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 ------------------------------------------------------
|
// -- 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() || './';
|
|
||||||
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
|
// 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
|
// 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
|
// earns Reboot's [role=button]{cursor:pointer}. Runs again after every htmx
|
||||||
@@ -109,33 +46,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delegated at the document, so it survives swaps and is bound only once.
|
// 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,
|
// The verse numbers and the dialog's own buttons are not here: opening the
|
||||||
// and Bootstrap owns the backdrop and Escape.
|
// dialog is an hx-on handler on #content, and everything inside it is
|
||||||
|
// markup that sw.js sends. This is the reset button and nothing else.
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
if (!event.target.closest) return;
|
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);
|
|
||||||
var reset = event.target.closest('#app-reset');
|
var reset = event.target.closest('#app-reset');
|
||||||
if (reset) resetApp(reset);
|
if (reset) resetApp(reset);
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||||
markVerses(event.target);
|
markVerses(event.target);
|
||||||
var target = event.detail.target;
|
var target = event.detail.target;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// Close the modal once a bookmark write has come back and swapped, leaving
|
|
||||||
// the status line up long enough to be read.
|
|
||||||
if (target.id === 'bookmark') return setTimeout(hideModal, 900);
|
|
||||||
// Both selects answer into the verse list, so this is either of them.
|
// Both selects answer into the verse list, so this is either of them.
|
||||||
if (target.id === 'reference-verse') return landOnReference();
|
if (target.id === 'reference-verse') return landOnReference();
|
||||||
if (target.id === 'content') { land(); showVersion(); }
|
if (target.id === 'content') { land(); showVersion(); }
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
'bookmark.njk',
|
'bookmark.njk',
|
||||||
'reference.njk',
|
'reference.njk',
|
||||||
'verses.njk',
|
'verses.njk',
|
||||||
|
'verse.njk',
|
||||||
'version.njk',
|
'version.njk',
|
||||||
'status.njk',
|
'status.njk',
|
||||||
'notice.njk'
|
'notice.njk'
|
||||||
|
|||||||
+64
-2
@@ -12,6 +12,7 @@
|
|||||||
// GET /api/bookmark the navbar slot
|
// GET /api/bookmark the navbar slot
|
||||||
// POST /api/bookmark set the bookmark, and return the slot
|
// POST /api/bookmark set the bookmark, and return the slot
|
||||||
// GET /api/reference the navbar's chapter and verse picker
|
// GET /api/reference the navbar's chapter and verse picker
|
||||||
|
// GET /api/verse the dialog for one verse
|
||||||
//
|
//
|
||||||
// Anything with a dot in its last segment is a file, not a route, and is
|
// Anything with a dot in its last segment is a file, not a route, and is
|
||||||
// served from the cache or the network without going near the router. Pages
|
// served from the cache or the network without going near the router. Pages
|
||||||
@@ -371,6 +372,40 @@ async function referenceOf(code, anchor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- verse
|
||||||
|
|
||||||
|
// The verse dialog's model: what to call this verse, and the two addresses for
|
||||||
|
// it. Null for anything that is not a verse of the book on screen.
|
||||||
|
//
|
||||||
|
// Checked against verses.json rather than taken at its word. at arrives from
|
||||||
|
// the page, and a dialog offering to bookmark an anchor the corpus does not
|
||||||
|
// have is a dialog offering a bookmark that lands nowhere -- so it is read
|
||||||
|
// back through the same map the options were built from, and the label comes
|
||||||
|
// out of that map rather than out of the id. Which is what makes a verse
|
||||||
|
// written "19-27" read as 26:19-27 and not as whatever splitting an id on an
|
||||||
|
// underscore would produce.
|
||||||
|
//
|
||||||
|
// href is relative, so a bookmark survives a change of host or port; url is
|
||||||
|
// absolute, because the only thing anyone does with it is paste it somewhere
|
||||||
|
// else. Both are spelled from the address bar, which is the only record of
|
||||||
|
// which book is on screen.
|
||||||
|
async function verseOf(page, at) {
|
||||||
|
const code = bookOf(page);
|
||||||
|
const anchor = parseAnchor(at);
|
||||||
|
if (!page || !code || !anchor || !anchor.verse) return null;
|
||||||
|
|
||||||
|
const reference = await referenceOf(code, at);
|
||||||
|
if (!reference) return null;
|
||||||
|
const option = reference.verses.find((verse) => verse.id === at);
|
||||||
|
if (!option) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref: code + ' ' + anchor.chapter + ':' + option.label,
|
||||||
|
href: code + '#' + at,
|
||||||
|
url: page.origin + page.pathname + '#' + at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const OFFLINE_NOTICE = ['Not available offline',
|
const OFFLINE_NOTICE = ['Not available offline',
|
||||||
'This page has not been opened on this device, so there is no copy stored ' +
|
'This page has not been opened on this device, so there is no copy stored ' +
|
||||||
'here. Reconnect and try again, or open one you have read before.'];
|
'here. Reconnect and try again, or open one you have read before.'];
|
||||||
@@ -516,6 +551,24 @@ router.get('/api/reference', async (call) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The dialog for one verse.
|
||||||
|
//
|
||||||
|
// at=v1_3 which verse, by the id of the element that is it -- the same
|
||||||
|
// spelling the picker's options use, read back the same way.
|
||||||
|
//
|
||||||
|
// The book is not in the URL, for the reason it is not in the picker's: this
|
||||||
|
// is chrome, it outlives the page under it, and HX-Current-URL is the address
|
||||||
|
// the page is actually showing.
|
||||||
|
//
|
||||||
|
// 204 for anything it cannot place. htmx swaps nothing on a 204, and the
|
||||||
|
// dialog opens on the swap, so a bad anchor is a click that does nothing
|
||||||
|
// rather than an empty dialog asking to bookmark it.
|
||||||
|
router.get('/api/verse', async (call) => {
|
||||||
|
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
|
||||||
|
if (!verse) return new Response('', { status: 204 });
|
||||||
|
return view.render('verse.njk', { verse: verse });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/api/bookmark', async (call) => {
|
router.post('/api/bookmark', async (call) => {
|
||||||
try {
|
try {
|
||||||
const form = await call.form();
|
const form = await call.form();
|
||||||
@@ -524,7 +577,16 @@ router.post('/api/bookmark', async (call) => {
|
|||||||
if (!ref || !href) return new Response('', { status: 400 });
|
if (!ref || !href) return new Response('', { status: 400 });
|
||||||
await withStore('readwrite',
|
await withStore('readwrite',
|
||||||
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
||||||
return bookmarkFragment('Bookmark set.');
|
// Only on the way out of a write that committed. base.njk closes the
|
||||||
|
// dialog on this, and a dialog that closed itself on a failure would take
|
||||||
|
// the line explaining the failure with it.
|
||||||
|
return new Response(await bookmarkFragment('Bookmark set.'), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'HX-Trigger-After-Settle': 'bookmark-saved'
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return bookmarkFragment('Could not save: storage unavailable.');
|
return bookmarkFragment('Could not save: storage unavailable.');
|
||||||
}
|
}
|
||||||
@@ -596,7 +658,7 @@ const BOOKMARK_KEY = 'bookmark';
|
|||||||
// who has never set one is treated as being at the beginning. Everything
|
// who has never set one is treated as being at the beginning. Everything
|
||||||
// downstream can assume a mark -- bookmark.njk's empty branch is for the boot
|
// downstream can assume a mark -- bookmark.njk's empty branch is for the boot
|
||||||
// page, which has no worker to ask, not for a reader without one.
|
// page, which has no worker to ask, not for a reader without one.
|
||||||
const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN#v1_1' };
|
const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
|
||||||
|
|
||||||
function openDb() {
|
function openDb() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
+54
-25
@@ -100,39 +100,68 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
{# The swap target. hx-target on <body> names this id, so every boosted
|
{# The swap target. hx-target on <body> names this id, so every boosted
|
||||||
link lands here -- remove it and a click does nothing at all while a
|
link lands here -- remove it and a click does nothing at all while a
|
||||||
full navigation still works. #}
|
full navigation still works.
|
||||||
<div id="content" class="col-12 col-xl-8" hx-history-elt>
|
|
||||||
|
It is also where a verse click becomes a request. htmx's bootstrap-modal
|
||||||
|
example puts hx-get on the element that opens the dialog; the element
|
||||||
|
here is a verse number in a generated book, and there are 38,000 of
|
||||||
|
them -- an attribute apiece would cost more than the whole shell, and a
|
||||||
|
listener apiece more than the reading. So one handler sits on the
|
||||||
|
container they all swap into, and names the verse it found. Delegated,
|
||||||
|
so it survives every swap: this element is never replaced, only filled.
|
||||||
|
|
||||||
|
hx-vals cannot do this. htmx evaluates it as Function("return (...)")()
|
||||||
|
with nothing passed in, so an expression there can read the document but
|
||||||
|
not the click -- and which verse was clicked is the one thing that has
|
||||||
|
to travel.
|
||||||
|
|
||||||
|
Enter and space go through the same door rather than a second one:
|
||||||
|
markVerses() in app.js gives every verse role=button and tabindex, and
|
||||||
|
a button that answers the keyboard by being clicked is a button with one
|
||||||
|
behaviour instead of two. #}
|
||||||
|
<div id="content" class="col-12 col-xl-8" hx-history-elt
|
||||||
|
hx-on:click="var verse = event.target.closest('.v[id]');
|
||||||
|
if (verse) htmx.ajax('GET', 'api/verse?at=' + verse.id,
|
||||||
|
{ target: '#verse-dialog .modal-content',
|
||||||
|
swap: 'innerHTML' });"
|
||||||
|
hx-on:keydown="if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
if (!event.target.matches('.v[id]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.click();">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include "version.njk" %}
|
{% include "version.njk" %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Chrome, so it is written once here instead of built in JavaScript on every
|
{# The verse dialog: empty in the page, filled per request. Bootstrap owns
|
||||||
page. app.js only fills in the verse it was opened for and shows it. #}
|
showing it, dismissing it, the backdrop and Escape; sw.js owns everything
|
||||||
|
inside, and templates/verse.njk is what lands in the modal-content below.
|
||||||
|
|
||||||
|
Opened by the arrival of the content rather than by data-bs-toggle on
|
||||||
|
whatever was clicked. The example puts that attribute on its trigger, which
|
||||||
|
is not available here for the same reason hx-get is not -- see #content
|
||||||
|
above -- and the swap is the honest signal anyway: there is nothing worth
|
||||||
|
showing until it is here. A verse the corpus does not have is answered 204,
|
||||||
|
which swaps nothing, which opens nothing.
|
||||||
|
|
||||||
|
after-swap and not after-settle: settling is the 20ms attribute pass and
|
||||||
|
this dialog has nothing to settle. Only the modal-content swap can reach
|
||||||
|
here -- the bookmark write targets the navbar slot, and the status line it
|
||||||
|
sends back rides in out of band, which fires its own event and not this one.
|
||||||
|
|
||||||
|
bookmark-saved comes from sw.js once the write has committed, and only then:
|
||||||
|
a dialog that closed itself on a failed write would take the message
|
||||||
|
explaining the failure with it. #}
|
||||||
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
<div class="modal fade" id="verse-dialog" tabindex="-1"
|
||||||
aria-labelledby="verse-dialog-ref" aria-hidden="true">
|
aria-labelledby="verse-dialog-ref" aria-hidden="true"
|
||||||
|
hx-on::after-swap="bootstrap.Modal.getOrCreateInstance(this).show();"
|
||||||
|
hx-on:bookmark-saved="var dialog = this;
|
||||||
|
setTimeout(function () {
|
||||||
|
bootstrap.Modal.getOrCreateInstance(dialog).hide();
|
||||||
|
}, 900);">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content"></div>
|
||||||
<div class="modal-header">
|
|
||||||
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref"></h2>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
|
||||||
aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
{% include "status.njk" %}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
|
|
||||||
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
|
||||||
<input type="hidden" name="ref" value="">
|
|
||||||
<input type="hidden" name="href" value="">
|
|
||||||
<button class="btn btn-outline-secondary" type="button"
|
|
||||||
data-action="copy">Copy link</button>
|
|
||||||
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{# The verse dialog's contents: what this verse is called, a link to it, and
|
||||||
|
the one thing you can do with it.
|
||||||
|
|
||||||
|
Rendered by sw.js for the verse that was clicked and swapped into the empty
|
||||||
|
modal-content in base.njk -- the shape htmx's bootstrap-modal example uses.
|
||||||
|
Bootstrap owns showing, dismissing, the backdrop and Escape; everything
|
||||||
|
here is what is inside.
|
||||||
|
|
||||||
|
Context: verse (ref, href, url).
|
||||||
|
|
||||||
|
All three are built in the worker, from the book in the address bar and the
|
||||||
|
anchor that was clicked. app.js used to read them off the page -- the book
|
||||||
|
out of article[data-name], the chapter out of the enclosing section -- which
|
||||||
|
put the reference in two places and left the spelling of an anchor known to
|
||||||
|
both the file that generates them and the file that scrapes them back.
|
||||||
|
|
||||||
|
The link is a field rather than a line of text because it is the fallback
|
||||||
|
for the button beside it: navigator.clipboard exists only in a secure
|
||||||
|
context, so over plain http:// from a LAN address there is nothing to write
|
||||||
|
with and a selected field is the whole of what is left. Readonly, so it
|
||||||
|
reads as something to take rather than something to fill in. #}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title fs-5 verse-dialog-ref" id="verse-dialog-ref">{{ verse.ref }}</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="visually-hidden" for="verse-dialog-url">Link to {{ verse.ref }}</label>
|
||||||
|
<input class="verse-dialog-url form-control form-control-sm" id="verse-dialog-url"
|
||||||
|
type="text" readonly value="{{ verse.url }}">
|
||||||
|
{% include "status.njk" %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
{# The form is the bookmark write and nothing else. ref and href are hidden
|
||||||
|
because they are this dialog's whole subject: the reader chose them by
|
||||||
|
clicking a verse, and there is nothing here to edit. #}
|
||||||
|
<form class="verse-dialog-actions d-flex flex-wrap gap-2 m-0"
|
||||||
|
hx-post="api/bookmark" hx-target="#bookmark" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="ref" value="{{ verse.ref }}">
|
||||||
|
<input type="hidden" name="href" value="{{ verse.href }}">
|
||||||
|
{# Selected first, written second: the selection is what makes the field
|
||||||
|
useful when the write is refused, and it costs nothing when it is not.
|
||||||
|
The status line is only told once the clipboard has actually taken it,
|
||||||
|
which is why the promise is answered rather than assumed. #}
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
hx-on:click="var field = document.getElementById('verse-dialog-url');
|
||||||
|
var line = document.getElementById('verse-dialog-status');
|
||||||
|
field.focus();
|
||||||
|
field.select();
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(field.value).then(
|
||||||
|
function () { line.textContent = 'Link copied.'; },
|
||||||
|
function () { line.textContent = 'Copy the link above.'; });
|
||||||
|
} else {
|
||||||
|
line.textContent = document.execCommand('copy')
|
||||||
|
? 'Link copied.' : 'Copy the link above.';
|
||||||
|
}">Copy link</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Set bookmark</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Reference in new issue
Block a user