script cache invalidation and verse menu
This commit is contained in:
@@ -394,18 +394,39 @@ for group in theological_synonym_groups:
|
|||||||
concept_mappings = json.load(open("data/concept_mappings.json", "r"))
|
concept_mappings = json.load(open("data/concept_mappings.json", "r"))
|
||||||
concept_mappings_dict = {mapping["query"].lower(): mapping for mapping in concept_mappings}
|
concept_mappings_dict = {mapping["query"].lower(): mapping for mapping in concept_mappings}
|
||||||
|
|
||||||
# Static assets are served with a very long cache lifetime, so their URLs carry a
|
# Static assets: each file's URL carries a hash of its own contents ("?v=...").
|
||||||
# hash of their contents ("?v=...") and change whenever the files do.
|
# A URL whose hash matches is served as immutable for a year; the hash changes
|
||||||
ASSET_VERSION: str = hashlib.md5(
|
# whenever the file does, so a deploy is picked up immediately and only the
|
||||||
b"".join(
|
# files that actually changed get downloaded again. Any other URL for the same
|
||||||
open(f, "rb").read()
|
# file (no hash, or an old one) is served with "no-cache", so it is revalidated
|
||||||
for f in ("styles/page.css", "js/collapsible.js", "js/shortcode.js", "js/verse_menu.js", "js/book_suggest.js")
|
# every time instead of lingering in a browser for a year.
|
||||||
)
|
ASSET_SOURCES = {
|
||||||
).hexdigest()[:8]
|
"/styles/page.css": ("styles/page.css", "text/css"),
|
||||||
|
"/js/collapsible.js": ("js/collapsible.js", "application/javascript"),
|
||||||
|
"/js/shortcode.js": ("js/shortcode.js", "application/javascript"),
|
||||||
|
"/js/verse_menu.js": ("js/verse_menu.js", "application/javascript"),
|
||||||
|
"/js/book_suggest.js": ("js/book_suggest.js", "application/javascript"),
|
||||||
|
}
|
||||||
|
ASSET_VERSIONS: dict = {
|
||||||
|
url: hashlib.md5(open(path, "rb").read()).hexdigest()[:8]
|
||||||
|
for url, (path, _mime) in ASSET_SOURCES.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def asset_url(url: str) -> str:
|
||||||
|
return f"{url}?v={ASSET_VERSIONS[url]}"
|
||||||
|
|
||||||
|
|
||||||
|
app.jinja_env.globals["asset_url"] = asset_url
|
||||||
|
|
||||||
|
|
||||||
def load_html(path: str) -> str:
|
def load_html(path: str) -> str:
|
||||||
return open(path, "r").read().replace("__V__", ASSET_VERSION)
|
"""Read an html file, filling each '/js/x.js?v=__V__' with that file's own version."""
|
||||||
|
return re.sub(
|
||||||
|
r"(/(?:js|styles)/[\w.\-]+)\?v=__V__",
|
||||||
|
lambda m: asset_url(m.group(1)),
|
||||||
|
open(path, "r").read(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
cached: dict = {
|
cached: dict = {
|
||||||
@@ -479,41 +500,42 @@ with app.app_context():
|
|||||||
template_content = load_html("html/index.html")
|
template_content = load_html("html/index.html")
|
||||||
cached["html_index"] = render_template_string(template_content, testament_data=cached["book_info_for_index"])
|
cached["html_index"] = render_template_string(template_content, testament_data=cached["book_info_for_index"])
|
||||||
|
|
||||||
@app.get("/js/shortcode.js")
|
def _make_asset_view(url_path: str, mimetype: str):
|
||||||
def serve_shortcode_js():
|
def view():
|
||||||
response = Response(cached.get("js_shortcode"), mimetype='application/javascript')
|
response = Response(cached["assets"][url_path], mimetype=mimetype)
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
response.add_etag()
|
||||||
return response
|
if request.args.get("v") == ASSET_VERSIONS[url_path]:
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
else:
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
|
return response.make_conditional(request)
|
||||||
|
view.__name__ = "asset_" + url_path.strip("/").replace("/", "_").replace(".", "_")
|
||||||
|
return view
|
||||||
|
|
||||||
@app.get("/js/book_suggest.js")
|
|
||||||
def serve_book_suggest_js():
|
|
||||||
response = Response(cached.get("js_book_suggest"), mimetype='application/javascript')
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/js/verse_menu.js")
|
cached["assets"] = {
|
||||||
def serve_verse_menu_js():
|
"/styles/page.css": cached["css_page"],
|
||||||
response = Response(cached.get("js_verse_menu"), mimetype='application/javascript')
|
"/js/collapsible.js": cached["js_collapsible"],
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
"/js/shortcode.js": cached["js_shortcode"],
|
||||||
return response
|
"/js/verse_menu.js": cached["js_verse_menu"],
|
||||||
|
"/js/book_suggest.js": cached["js_book_suggest"],
|
||||||
|
}
|
||||||
|
for _url, (_path, _mime) in ASSET_SOURCES.items():
|
||||||
|
app.add_url_rule(_url, view_func=_make_asset_view(_url, _mime), methods=["GET"])
|
||||||
|
|
||||||
@app.get("/js/collapsible.js")
|
|
||||||
def serve_collapsible_js():
|
|
||||||
response = Response(cached.get("js_collapsible"), mimetype='application/javascript')
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/styles/page.css")
|
@app.after_request
|
||||||
def serve_page_css():
|
def html_pages_are_never_stale(response):
|
||||||
response = Response(cached.get("css_page"), mimetype='text/css')
|
"""Pages reference versioned assets, so they must always be revalidated to see new versions."""
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
if response.mimetype == "text/html" and "Cache-Control" not in response.headers:
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.get("/favicon.ico")
|
@app.get("/favicon.ico")
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
def serve_favicon():
|
def serve_favicon():
|
||||||
response = Response(cached.get("favicon_svg"), mimetype='image/svg+xml')
|
response = Response(cached.get("favicon_svg"), mimetype='image/svg+xml')
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000'
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def static_page(path: str):
|
def static_page(path: str):
|
||||||
@@ -529,7 +551,7 @@ def static_html_doc(title: str, body: str):
|
|||||||
"<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>"
|
"<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>"
|
||||||
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
|
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
|
||||||
f"<title>{title} - KJV API</title>"
|
f"<title>{title} - KJV API</title>"
|
||||||
f"<link rel='stylesheet' href='/styles/page.css?v={ASSET_VERSION}'></head><body>"
|
f"<link rel='stylesheet' href='{asset_url('/styles/page.css')}'></head><body>"
|
||||||
"<header><a href='/'>Home</a> <a href='/find'>Quick Search</a></header>"
|
"<header><a href='/'>Home</a> <a href='/find'>Quick Search</a></header>"
|
||||||
f"{body}</body></html>"
|
f"{body}</body></html>"
|
||||||
)
|
)
|
||||||
@@ -928,7 +950,7 @@ def parse_query(query_override: str = None):
|
|||||||
result += "<style>" + cached["css_collapsible"] + "</style>"
|
result += "<style>" + cached["css_collapsible"] + "</style>"
|
||||||
result += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
|
result += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
|
||||||
result += definitions_html
|
result += definitions_html
|
||||||
result += f"<script src='/js/collapsible.js?v={ASSET_VERSION}'></script>"
|
result += f"<script src='{asset_url('/js/collapsible.js')}'></script>"
|
||||||
|
|
||||||
result += "</div>"
|
result += "</div>"
|
||||||
return result
|
return result
|
||||||
@@ -1687,7 +1709,7 @@ def search():
|
|||||||
|
|
||||||
@app.get("/browse") # not implemented. TO be moved to frontend.
|
@app.get("/browse") # not implemented. TO be moved to frontend.
|
||||||
def bible_reader():
|
def bible_reader():
|
||||||
return render_template("reader.html", books=cached.get("book_names"), v=ASSET_VERSION)
|
return render_template("reader.html", books=cached.get("book_names"))
|
||||||
# We should return an index of all books with
|
# We should return an index of all books with
|
||||||
# drop down menus of each chapter, and a search
|
# drop down menus of each chapter, and a search
|
||||||
# bar at the top for quick seeking, which can use
|
# bar at the top for quick seeking, which can use
|
||||||
@@ -1718,7 +1740,7 @@ def sequential_report(uid=None):
|
|||||||
seq_conn = sqlite3.connect("data/seq.db")
|
seq_conn = sqlite3.connect("data/seq.db")
|
||||||
seq_cur = seq_conn.cursor()
|
seq_cur = seq_conn.cursor()
|
||||||
uids = seq_cur.execute("select * from reading_tracker").fetchall()
|
uids = seq_cur.execute("select * from reading_tracker").fetchall()
|
||||||
return render_template("seq_report.html", uids=uids, v=ASSET_VERSION)
|
return render_template("seq_report.html", uids=uids)
|
||||||
|
|
||||||
@app.get("/screport") # shows current remembered shortcodes
|
@app.get("/screport") # shows current remembered shortcodes
|
||||||
def shortcode_report(shortcode=None):
|
def shortcode_report(shortcode=None):
|
||||||
@@ -1730,7 +1752,7 @@ def shortcode_report(shortcode=None):
|
|||||||
shortcodes = short_cur.execute(
|
shortcodes = short_cur.execute(
|
||||||
"SELECT * FROM shortcodes"
|
"SELECT * FROM shortcodes"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return render_template("shortcode_report.html", shortcodes=shortcodes, v=ASSET_VERSION)
|
return render_template("shortcode_report.html", shortcodes=shortcodes)
|
||||||
|
|
||||||
|
|
||||||
def sequential_purge():
|
def sequential_purge():
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Books - KJV API</title>
|
<title>Books - KJV API</title>
|
||||||
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
|
<link rel="stylesheet" href="{{ asset_url('/styles/page.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
+56
-1
@@ -14,6 +14,9 @@
|
|||||||
.item p { margin: 0.25rem 0; }
|
.item p { margin: 0.25rem 0; }
|
||||||
.item button { min-height: 36px; padding: 0.2rem 0.9rem; font-size: 0.85rem;
|
.item button { min-height: 36px; padding: 0.2rem 0.9rem; font-size: 0.85rem;
|
||||||
background-color: var(--color-bg-tertiary); color: var(--color-text-primary); border: 1px solid var(--color-border); }
|
background-color: var(--color-bg-tertiary); color: var(--color-text-primary); border: 1px solid var(--color-border); }
|
||||||
|
.item .mynote { white-space: pre-wrap; border-left: 3px solid #d9a93b; padding-left: 0.6rem; color: var(--color-text-secondary); }
|
||||||
|
.file-btn { display: inline-block; padding: 0.6rem 1.2rem; border: 1px solid var(--color-border); border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg-tertiary); font-weight: 600; }
|
||||||
.empty { color: var(--color-text-muted); }
|
.empty { color: var(--color-text-muted); }
|
||||||
.note { font-size: 0.9rem; color: var(--color-text-muted); }
|
.note { font-size: 0.9rem; color: var(--color-text-muted); }
|
||||||
</style>
|
</style>
|
||||||
@@ -26,6 +29,8 @@
|
|||||||
<h2>Saved</h2>
|
<h2>Saved</h2>
|
||||||
<p class="note">Saved in this browser only. Clearing site data or using another device starts fresh.</p>
|
<p class="note">Saved in this browser only. Clearing site data or using another device starts fresh.</p>
|
||||||
|
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<div id="notes"></div>
|
||||||
<h3>Favorite verses</h3>
|
<h3>Favorite verses</h3>
|
||||||
<div id="fav"></div>
|
<div id="fav"></div>
|
||||||
<h3>Definitions</h3>
|
<h3>Definitions</h3>
|
||||||
@@ -33,8 +38,17 @@
|
|||||||
<h3>Cross references</h3>
|
<h3>Cross references</h3>
|
||||||
<div id="xrefs"></div>
|
<div id="xrefs"></div>
|
||||||
|
|
||||||
|
<h3>Backup</h3>
|
||||||
|
<p class="note">Browser storage can be cleared without warning. Download a copy of your notes and saved items, and restore it here or on another device.</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" id="backup">Download backup</button>
|
||||||
|
<label class="choice"><input type="file" id="restore" accept="application/json,.json" hidden>
|
||||||
|
<span class="file-btn">Restore from backup</span></label>
|
||||||
|
</p>
|
||||||
|
<p class="note" id="backup-status" role="status"></p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var KEYS = { fav: 'kjv.favorites', defs: 'kjv.definitions', xrefs: 'kjv.crossrefs' };
|
var KEYS = { notes: 'kjv.notes', fav: 'kjv.favorites', defs: 'kjv.definitions', xrefs: 'kjv.crossrefs' };
|
||||||
|
|
||||||
function load(key) {
|
function load(key) {
|
||||||
try { var v = JSON.parse(localStorage.getItem(key)); return Array.isArray(v) ? v : []; }
|
try { var v = JSON.parse(localStorage.getItem(key)); return Array.isArray(v) ? v : []; }
|
||||||
@@ -76,6 +90,11 @@ function refLink(ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
|
render(KEYS.notes, 'notes', 'No notes yet. Tap a verse (or its \u22ef button) and choose Note.', function (box, n) {
|
||||||
|
box.appendChild(refLink(n.ref));
|
||||||
|
if (n.text) { box.appendChild(el('div', 'text', n.text)); }
|
||||||
|
box.appendChild(el('p', 'mynote', n.note));
|
||||||
|
});
|
||||||
render(KEYS.fav, 'fav', 'No favorites yet. Tap a verse and choose Favorite.', function (box, f) {
|
render(KEYS.fav, 'fav', 'No favorites yet. Tap a verse and choose Favorite.', function (box, f) {
|
||||||
box.appendChild(refLink(f.ref));
|
box.appendChild(refLink(f.ref));
|
||||||
box.appendChild(el('div', 'text', f.text));
|
box.appendChild(el('div', 'text', f.text));
|
||||||
@@ -92,6 +111,42 @@ function draw() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
|
|
||||||
|
document.getElementById('backup').addEventListener('click', function () {
|
||||||
|
var data = { kjv_backup: 1, saved: new Date().toISOString() };
|
||||||
|
Object.keys(KEYS).forEach(function (name) { data[name] = load(KEYS[name]); });
|
||||||
|
var blob = new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = 'kjv-saved-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('restore').addEventListener('change', function (event) {
|
||||||
|
var file = event.target.files[0];
|
||||||
|
var status = document.getElementById('backup-status');
|
||||||
|
if (!file) { return; }
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(reader.result);
|
||||||
|
if (!data || data.kjv_backup !== 1) { throw new Error('not a backup'); }
|
||||||
|
if (!confirm('Replace the notes and saved items in this browser with this backup?')) { return; }
|
||||||
|
Object.keys(KEYS).forEach(function (name) {
|
||||||
|
if (Array.isArray(data[name])) { localStorage.setItem(KEYS[name], JSON.stringify(data[name])); }
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
status.textContent = 'Backup restored.';
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'That file is not a valid backup.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sequence Report - KJV API</title>
|
<title>Sequence Report - KJV API</title>
|
||||||
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
|
<link rel="stylesheet" href="{{ asset_url('/styles/page.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Shortcode Report - KJV API</title>
|
<title>Shortcode Report - KJV API</title>
|
||||||
<link rel="stylesheet" href="/styles/page.css?v={{ v }}">
|
<link rel="stylesheet" href="{{ asset_url('/styles/page.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
+128
-14
@@ -1,8 +1,9 @@
|
|||||||
// Tap a verse to open a small menu: favorite it, look up the 1828 Webster
|
// Every verse card gets an actions button (and the card itself is tappable)
|
||||||
// definition of its words, or see its cross references. Everything saved
|
// that opens a small menu: favorite the verse, write a note on it, look up the
|
||||||
// lives in this browser's localStorage (read back on the /saved page).
|
// 1828 Webster definition of its words, or see its cross references.
|
||||||
|
// Everything saved lives in this browser's localStorage (see the /saved page).
|
||||||
(function () {
|
(function () {
|
||||||
var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs" };
|
var KEYS = { fav: "kjv.favorites", defs: "kjv.definitions", xrefs: "kjv.crossrefs", notes: "kjv.notes" };
|
||||||
var open = null; // { box, menu }
|
var open = null; // { box, menu }
|
||||||
|
|
||||||
function load(key) {
|
function load(key) {
|
||||||
@@ -71,12 +72,72 @@
|
|||||||
return "/find?kw=" + encodeURIComponent(ref) + "&view=rich";
|
return "/find?kw=" + encodeURIComponent(ref) + "&view=rich";
|
||||||
}
|
}
|
||||||
|
|
||||||
function markFavorites(root) {
|
function noteFor(ref) {
|
||||||
|
var list = load(KEYS.notes);
|
||||||
|
var at = indexOf(list, function (n) { return n.ref === ref; });
|
||||||
|
return at >= 0 ? list[at].note : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows the saved note (if any) under the verse text.
|
||||||
|
function renderNote(box) {
|
||||||
|
var old = box.querySelector(".verse-note");
|
||||||
|
if (old) {
|
||||||
|
old.remove();
|
||||||
|
}
|
||||||
|
var info = verseInfo(box);
|
||||||
|
var note = noteFor(info.ref);
|
||||||
|
box.classList.toggle("has-note", !!note);
|
||||||
|
if (!note) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = box.querySelector(".verse-text");
|
||||||
|
var node = el("div", "verse-note", note);
|
||||||
|
node.setAttribute("title", "Your note");
|
||||||
|
if (text) {
|
||||||
|
text.after(node);
|
||||||
|
} else {
|
||||||
|
box.appendChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSaved(root) {
|
||||||
var favs = load(KEYS.fav);
|
var favs = load(KEYS.fav);
|
||||||
var boxes = (root || document).querySelectorAll(".verse-container");
|
var boxes = (root || document).querySelectorAll(".verse-container");
|
||||||
for (var i = 0; i < boxes.length; i++) {
|
for (var i = 0; i < boxes.length; i++) {
|
||||||
var ref = verseInfo(boxes[i]).ref;
|
var ref = verseInfo(boxes[i]).ref;
|
||||||
boxes[i].classList.toggle("is-fav", indexOf(favs, function (f) { return f.ref === ref; }) >= 0);
|
boxes[i].classList.toggle("is-fav", indexOf(favs, function (f) { return f.ref === ref; }) >= 0);
|
||||||
|
renderNote(boxes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visible way in to the menu on every verse, so nobody has to guess that the card is tappable.
|
||||||
|
function addMenuButtons() {
|
||||||
|
var boxes = document.querySelectorAll(".verse-container");
|
||||||
|
for (var i = 0; i < boxes.length; i++) {
|
||||||
|
var header = boxes[i].querySelector(".verse-reference");
|
||||||
|
if (!header || header.querySelector(".vm-more")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var button = el("button", "vm-more", "\u22ef");
|
||||||
|
button.type = "button";
|
||||||
|
button.setAttribute("aria-label", "Verse actions: notes, favorite, define, cross references");
|
||||||
|
button.setAttribute("title", "Notes, favorite, define, cross references");
|
||||||
|
button.addEventListener("click", (function (box) {
|
||||||
|
return function () { toggleMenu(box); };
|
||||||
|
})(boxes[i]));
|
||||||
|
header.appendChild(button);
|
||||||
|
}
|
||||||
|
var count = document.querySelector(".results-count");
|
||||||
|
if (count && boxes.length && !document.querySelector(".verse-hint")) {
|
||||||
|
count.after(el("div", "verse-hint", "Tap a verse, or its \u22ef button, to add a note, favorite it, define words or see cross references."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu(box) {
|
||||||
|
if (open && open.box === box) {
|
||||||
|
closeMenu();
|
||||||
|
} else {
|
||||||
|
openMenu(box);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +242,49 @@
|
|||||||
.catch(function () { panel.textContent = "Could not load cross references."; });
|
.catch(function () { panel.textContent = "Could not load cross references."; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notes save as you type; clearing the box deletes the note.
|
||||||
|
function showNote(info, panel, box, onChange) {
|
||||||
|
panel.textContent = "";
|
||||||
|
var area = el("textarea", "vm-note-input");
|
||||||
|
area.rows = 4;
|
||||||
|
area.placeholder = "Your note on " + info.ref + "...";
|
||||||
|
area.setAttribute("aria-label", "Note for " + info.ref);
|
||||||
|
area.value = noteFor(info.ref);
|
||||||
|
var status = el("div", "vm-hint", "Saved in this browser as you type.");
|
||||||
|
var timer = null;
|
||||||
|
function commit() {
|
||||||
|
var value = area.value.trim();
|
||||||
|
var list = load(KEYS.notes);
|
||||||
|
var at = indexOf(list, function (n) { return n.ref === info.ref; });
|
||||||
|
if (!value) {
|
||||||
|
if (at >= 0) {
|
||||||
|
list.splice(at, 1);
|
||||||
|
}
|
||||||
|
} else if (at >= 0) {
|
||||||
|
list[at].note = value;
|
||||||
|
list[at].t = Date.now();
|
||||||
|
} else {
|
||||||
|
list.unshift({ ref: info.ref, text: info.text, note: value, t: Date.now() });
|
||||||
|
}
|
||||||
|
save(KEYS.notes, list);
|
||||||
|
renderNote(box);
|
||||||
|
onChange();
|
||||||
|
status.textContent = value ? "Saved." : "Note removed.";
|
||||||
|
}
|
||||||
|
area.addEventListener("input", function () {
|
||||||
|
status.textContent = "Saving\u2026";
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(commit, 400);
|
||||||
|
});
|
||||||
|
area.addEventListener("blur", function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
commit();
|
||||||
|
});
|
||||||
|
panel.appendChild(area);
|
||||||
|
panel.appendChild(status);
|
||||||
|
area.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function openMenu(box) {
|
function openMenu(box) {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
var info = verseInfo(box);
|
var info = verseInfo(box);
|
||||||
@@ -206,6 +310,19 @@
|
|||||||
paintFav();
|
paintFav();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var noteBtn = el("button", "vm-btn");
|
||||||
|
noteBtn.type = "button";
|
||||||
|
function paintNote() {
|
||||||
|
var has = !!noteFor(info.ref);
|
||||||
|
noteBtn.textContent = has ? "\u270e Edit note" : "\u270e Note";
|
||||||
|
noteBtn.classList.toggle("saved", has);
|
||||||
|
}
|
||||||
|
paintNote();
|
||||||
|
noteBtn.addEventListener("click", function () {
|
||||||
|
panel.hidden = false;
|
||||||
|
showNote(info, panel, box, paintNote);
|
||||||
|
});
|
||||||
|
|
||||||
var define = el("button", "vm-btn", "Define words");
|
var define = el("button", "vm-btn", "Define words");
|
||||||
define.type = "button";
|
define.type = "button";
|
||||||
define.addEventListener("click", function () {
|
define.addEventListener("click", function () {
|
||||||
@@ -225,7 +342,7 @@
|
|||||||
close.setAttribute("aria-label", "Close menu");
|
close.setAttribute("aria-label", "Close menu");
|
||||||
close.addEventListener("click", closeMenu);
|
close.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
[fav, define, xref, close].forEach(function (b) { actions.appendChild(b); });
|
[fav, noteBtn, define, xref, close].forEach(function (b) { actions.appendChild(b); });
|
||||||
menu.appendChild(actions);
|
menu.appendChild(actions);
|
||||||
menu.appendChild(panel);
|
menu.appendChild(panel);
|
||||||
box.appendChild(menu);
|
box.appendChild(menu);
|
||||||
@@ -241,25 +358,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// links, buttons and the open menu keep their own behaviour; so does selecting text
|
// links, buttons and the open menu keep their own behaviour; so does selecting text
|
||||||
if (event.target.closest("a, button, input, .verse-menu")) {
|
if (event.target.closest("a, button, input, textarea, .verse-menu")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var selection = window.getSelection && window.getSelection().toString();
|
var selection = window.getSelection && window.getSelection().toString();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (open && open.box === box) {
|
toggleMenu(box);
|
||||||
closeMenu();
|
|
||||||
} else {
|
|
||||||
openMenu(box);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (event) {
|
document.addEventListener("keydown", function (event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape" && !(event.target && event.target.tagName === "TEXTAREA")) {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
markFavorites();
|
markSaved();
|
||||||
|
addMenuButtons();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+69
-1
@@ -456,7 +456,10 @@ button:hover {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-reference a {
|
.verse-reference a {
|
||||||
@@ -610,6 +613,71 @@ button:hover {
|
|||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Always-visible entry point to the verse menu, hint line, and notes */
|
||||||
|
.vm-more {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
margin: -6px -8px -6px 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-more:hover,
|
||||||
|
.verse-container.selected .vm-more {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-hint {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: calc(-1 * var(--space-xs)) 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-note {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-left: 3px solid #d9a93b;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-note::before {
|
||||||
|
content: "\270e ";
|
||||||
|
color: #d9a93b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-note-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 6rem;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 1rem; /* 16px minimum stops iOS zooming into the field */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-note-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Reading plan ---- */
|
/* ---- Reading plan ---- */
|
||||||
.plan-summary {
|
.plan-summary {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
|||||||
Reference in New Issue
Block a user